Browser DevTools for PWAs¶
Browser DevTools are where you find out what the browser actually did with your Progressive Web App: which manifest it parsed, which service worker version is active and which one is waiting, what is in Cache Storage and IndexedDB, whether a response came from the worker or the network, and why the app is or is not installable. Chromium browsers (Chrome, Edge, Opera, Samsung Internet on desktop via remote debugging) have the most complete tooling in the Application panel, backed by internal pages such as chrome://serviceworker-internals and chrome://web-app-internals. Firefox covers service workers and the manifest through its Application panel and about:debugging, and Safari's Web Inspector is the only way to look inside iOS and iPadOS Home Screen web apps. This page documents every PWA-relevant control in each of them, what it really does underneath, and step-by-step debugging recipes for the problems you will actually hit.
Key takeaways
- The Chromium Application panel is the primary PWA debugger: the Manifest pane shows the parsed manifest, computed app ID and installability errors; the Service workers pane controls lifecycle and simulates
push,syncandperiodicsyncevents. - Update on reload and Bypass for network are persistent DevTools settings, not per-page toggles. Forgetting them turned on is the most common reason "it works in DevTools but not for users".
- The Network panel marks worker-served responses as
(ServiceWorker)in the Size column, static-routing matches as(ServiceWorker router), and lets you isolate traffic withis:service-worker-interceptedandis:service-worker-initiated. chrome://serviceworker-internalsshows every registration in the profile, including fetch-handler type, static router rules and navigation preload state, and can pause a worker on startup so you can debug theinstallhandler.- Use
chrome://inspectwith USB debugging and port forwarding for Android (including installed WebAPKs), and Safari's Develop menu with Web Inspector enabled for iOS, iPadOS and macOS web apps. - Firefox lists every registration at
about:debugging#/runtime/this-firefoxand still shipsabout:serviceworkers, which is the only built-in view on Firefox for Android without a desktop connection. - Safari's Web Inspector has no manifest viewer, no Cache Storage browser and no push simulator; inspect those from the Console and test push against the real Apple push service.
Which tool answers which question¶
Before diving into individual panes, it helps to know where each question is answered fastest. The table below maps common PWA debugging questions to the tool and location that answers them in each engine.
| Question | Chrome / Edge | Firefox | Safari |
|---|---|---|---|
| Is my manifest parsed, and with which values? | Application › Manifest | Application › Manifest | Console: fetch the manifest; no viewer |
| Why is the app not installable? | Application › Manifest › Installability, Errors and warnings | Not applicable (no manifest-driven install on desktop) | Not applicable (Add to Home Screen is always offered) |
| Which service worker version is active or waiting? | Application › Service workers | Application › Service Workers, about:debugging | Develop › Service Workers |
| Did this response come from the service worker? | Network › Size column, Timing tab | Network › Transferred column | Network › Source / Transferred, tooltip |
| What is in Cache Storage? | Application › Cache storage | Storage › Cache Storage | Console: caches.keys() |
| What is in IndexedDB? | Application › IndexedDB | Storage › Indexed DB | Storage › Indexed Databases |
Fire a test push event | Application › Service workers › Push | about:debugging › Push | Not available; send a real push |
Fire a test sync / periodicsync event | Application › Service workers | Not supported by the engine | Not supported by the engine |
| History of background events while DevTools was closed | Application › Background services | Not available | Not available |
| Every registration in the profile | chrome://serviceworker-internals | about:debugging, about:serviceworkers | Develop › Service Workers (running only) |
| Installed-app state (IDs, OS integration) | chrome://web-app-internals, chrome://webapks (Android) | Not available | Not available |
The rest of this page follows roughly that order: Chromium first because it exposes the most, then the internal pages, then Firefox and Safari, then recipes.
The Chromium Application panel at a glance¶
Open DevTools (Ctrl+Shift+I on Windows and Linux, Cmd+Option+I on macOS) and select the Application panel. In an installed desktop PWA running in its own window, the same shortcut works, or right-click the page and choose Inspect. The sidebar is grouped into four sections:
- Application
- Manifest, Service workers, Storage, and in current Chrome builds an experimental WebMCP pane that is not related to PWAs.
- Storage
- Local storage, Session storage, Extension storage (extensions only), IndexedDB, Cookies, Private state tokens, Interest groups, Shared storage, Cache storage and Storage buckets.
- Background services
- Back/forward cache, Background fetch, Background sync, Bounce tracking mitigations, Notifications, Payment handler, Periodic background sync, Speculative loads, Push messaging, Reporting API and Device bound sessions.
- Frames
- The frame tree for the page: the top document, nested iframes, opened windows and dedicated workers, each with security and isolation details.
Microsoft Edge ships the same Chromium DevTools front end, so every Chrome control described below exists in Edge with the same label (Edge's documentation calls the panel the "Application tool"). Where internal URLs differ, Edge uses edge:// instead of chrome://: for example the See all registrations link opens edge://serviceworker-internals.
Undock DevTools for installed apps
When you debug an installed desktop PWA in its standalone window, DevTools opens in a separate window. Keep it that way: docking DevTools inside a narrow app window changes the viewport and can trigger responsive breakpoints and resize handlers that you are not trying to debug.
The Manifest pane¶
The Manifest pane shows what Chromium's manifest parser produced, not what you wrote. That distinction matters: members with invalid values are dropped silently by the parser (the spec requires it to ignore invalid members rather than fail), and the pane is the fastest way to see what was dropped.
How DevTools obtains the manifest¶
DevTools does not fetch your manifest itself. It asks the renderer for the manifest that the page's <link rel="manifest"> resolved to, using Chrome DevTools Protocol (CDP) commands you can also call from automation:
| CDP command | What it returns | Used for |
|---|---|---|
Page.getAppManifest | Manifest URL, raw JSON text (data), parse errors, and the parsed manifest | Every section of the pane |
Page.getInstallabilityErrors (experimental) | An array of installability error IDs with arguments | The Installability section |
Page.getAppId (experimental) | appId (from id or computed from start_url) and recommendedId | The Computed app ID row and its note |
Because the browser fetches the manifest as a CORS request whose credentials mode is omit unless you add crossorigin="use-credentials" to the <link> (the HTML specification's rule for rel="manifest"), a manifest behind cookie authentication can load fine when you open its URL in a tab but fail in the pane. If the pane says No manifest detected while the URL works in a tab, check the crossorigin attribute and the response status in the Network panel (filter by manifest).
Errors and warnings, and Installability¶
The top of the pane has a link to the manifest file, followed by Errors and warnings (parser errors plus DevTools' own lint checks) and, when something blocks installation, an Installability section. The installability messages map to Chromium's internal installability checks. The ones you are most likely to see:
| Message in DevTools | What it means | Fix |
|---|---|---|
| Page isn't served from a secure origin | Not HTTPS and not localhost / loopback | Serve over HTTPS or use localhost |
Page has no manifest <link> URL | No <link rel="manifest"> found in the document | Add the link element to every installable page |
| Manifest couldn't be fetched, is empty, or couldn't be parsed | Network error, non-2xx status, or invalid JSON | Check the request in the Network panel; validate the JSON |
| Manifest doesn't contain a 'name' or 'short_name' field | Neither name member is present | Add name (and ideally short_name) |
| Manifest 'display' property must be one of 'standalone', 'fullscreen', or 'minimal-ui' | display is browser (or invalid, which falls back to browser) | Set display, or put a supported value first in display_override |
| Manifest doesn't contain an icon that fits… | No square PNG, SVG or WebP icon of the required minimum size with purpose including any | Provide at least a 192×192 and a 512×512 any icon |
| Couldn't download a required icon from the manifest / Downloaded icon was empty or corrupted | The chosen icon URL failed or decoded to nothing | Check the icon request and its Content-Type |
| A URL in the manifest contains a username, password, or port | Reported for the Android WebAPK check (url-not-supported-for-webapk): WebAPKs cannot be minted for such URLs | Remove credentials or non-default ports from manifest URLs |
| Page is loaded in an incognito window | Install is disabled in Incognito and Guest | Test in a normal profile |
| The app is already installed | Chromium already has an app with this manifest ID | Uninstall from chrome://apps or the app's menu to retest |
| Manifest specifies 'prefer_related_applications': true | Chrome on Android will promote the native app instead | Remove the member or set it to false |
You may also still see the legacy messages Page doesn't work offline or "Couldn't check service worker without a 'start_url' field". Chrome removed the requirement for a service worker with a fetch handler from its installability criteria in Chrome 108 on Android and Chrome 112 on desktop, so on current Chrome these no longer block installation from the browser menu. At that time the automatic install prompt still required a fetch handler; current Chromium's install promotion pipeline has no service worker check at all. The full, current criteria per browser are on Installability Criteria.
The lint checks in Errors and warnings are more useful than the installability list for polish. Chromium's current list includes:
- "Declaring an icon with 'purpose' of 'any maskable' is discouraged. It's likely to look incorrect on some platforms due to too much or too little padding."
- "Most operating systems require square icons. Include at least one square icon in the array."
- "Actual size (W×H)px of icon url doesn't match specified size (W×Hpx)", which catches
sizesvalues that lie about the file. - "icon doesn't specify its size in the manifest" and "should specify its size as
[width]x[height]". - "Shortcut #N should include a 96×96 pixel icon."
- "The maximum number of shortcuts is platform dependent. Some shortcuts may not be available."
- For screenshots: size must be at least 320×320 and at most 3840×3840; neither dimension may be more than 2.3 times the other; the first
sizesentry must be a pixel size rather thanany; all screenshots with the sameform_factormust share the first one's aspect ratio; no more than 8 are displayed on desktop and 5 on mobile. - "Richer PWA install UI won't be available on desktop. Add at least one screenshot with the
form_factorset towide." and the mobile equivalent for screenshots whoseform_factoris unset or notwide.
Each of those checks corresponds to a rule described on Icons & Maskable Icons, App Shortcuts and Rich Install UI.
Identity and the computed app ID¶
The Identity section shows Name, Short name, Description (with a "Description may be truncated" warning for long text) and Computed app ID. The app ID is what the browser uses to decide whether a manifest updates an existing installed app or describes a new one. When your manifest has no id, DevTools shows the note:
Note:
idisn't specified in the manifest,start_urlis used instead. To specify an app ID that matches the current identity, set theidfield to …
followed by a button that copies the suggested value (the path of the current computed ID, relative to the origin). Copy it verbatim into your manifest before you change start_url; otherwise every existing installation becomes orphaned from the new identity. The mechanics are explained on App Identity & Updates.
Presentation¶
The Presentation section lists Start URL, Theme color, Background color, Orientation, Display and, when present, New note URL (note_taking.new_note_url). The Start URL is shown resolved against the manifest URL, which is the quickest way to catch the classic bug of a relative start_url resolving against the manifest's directory rather than the page. Colors are shown after parsing: an invalid CSS color is dropped and simply does not appear.
Protocol handlers¶
When the manifest declares protocol_handlers, the Protocol handlers section lists them and, for an installed app, offers a text box next to the scheme (for example web+coffee://) plus a Test protocol button. The button navigates to a URL with that scheme, which exercises the full OS-level handler path including the permission prompt the first time. On Windows you can also test from outside the browser with the Run dialog (Win+R) and a URL such as web+coffee:latte. See Protocol Handlers & Launch Handling for how %s substitution and the permission model work.
Icons and the maskable safe area¶
The Icons section renders every icon at its declared size, grouped by purpose. The Show only the minimum safe area for maskable icons checkbox crops each maskable icon to the circle of radius 40% of the icon size, centered, which is the only region the maskable icon specification guarantees will remain visible after the platform applies its mask. If any part of your logo disappears with the checkbox ticked, it will be clipped on some launcher.
Window Controls Overlay emulation¶
If the manifest's display_override contains window-controls-overlay, the Window Controls Overlay section confirms "Chrome found the window-controls-overlay value for the display_override field in the manifest" and offers an Emulate Window Controls Overlay checkbox with an OS selector (Windows, macOS, Linux). With emulation on, DevTools draws the title-bar controls for that OS over the page and populates the titlebar-area-* environment variables and navigator.windowControlsOverlay.getTitlebarAreaRect(), so you can iterate on the title bar layout in a normal tab without installing the app. Real behavior still needs an installed app because the user can toggle the overlay off at runtime, which fires geometrychange. Details are on Window Controls Overlay.
Shortcuts and screenshots¶
Each shortcut appears as Shortcut #N with its name, short name, description, URL and icons; each screenshot appears as Screenshot #N with its image, Form factor, Label and Platform. Use these sections to confirm that URLs resolved against the manifest URL, and that screenshots load (a broken screenshot is silently omitted from the install dialog).
Triggering installation while debugging¶
The pane itself has no install button. On desktop Chrome and Edge, use the install icon in the address bar or the browser menu. To retest from scratch, uninstall the app (from the app window's menu, from chrome://apps on desktop, or from the OS), then reload the page; the Installability section and the beforeinstallprompt event re-evaluate on every navigation. For automated install and launch in tests, the CDP PWA domain provides PWA.install, PWA.uninstall, PWA.launch, PWA.launchFilesInApp, PWA.openCurrentPageInApp, PWA.getOsAppState and PWA.changeAppUserSettings; see Automated Testing.
The Service workers pane¶
The Service workers pane is where you spend most of your PWA debugging time. It shows one section per registration whose scope matches the inspected page's origin, a toolbar of three global checkboxes, and a link to all other registrations.
The toolbar: Offline, Update on reload, Bypass for network¶
The three checkboxes at the top look like per-page options, but they are not. They are DevTools settings with global effect while DevTools is open, and two of them persist across DevTools sessions.
| Checkbox | What it does | Scope and persistence | Traps |
|---|---|---|---|
| Offline | Sets the same network-conditions override as the Network panel's throttling menu to Offline. | Applies to every target DevTools is attached to, including the service worker, so the worker's own fetch() calls fail with a TypeError just as they would with no connection. | It emulates no network, not flaky network. Requests fail instantly rather than timing out, so it does not exercise your network timeouts. |
| Update on reload | Tooltip: "On page reload, force the service worker to update, and activate it." Each navigation triggers an update that installs a new version even if the script is byte-identical, then activates it immediately. | Persistent DevTools setting; only acts while DevTools is open for the page. | install runs on every reload, so precaching re-downloads everything and the waiting phase never happens. Your update UI ("New version available, reload") can never be tested with this on. |
| Bypass for network | Tooltip: "Bypass the service worker and load resources from the network." Implemented with the CDP command Network.setBypassServiceWorker. | Persistent DevTools setting. | The worker still installs, activates and controls the page (navigator.serviceWorker.controller is non-null), and still receives push, sync and message events. Only fetch events are skipped. Code that reads controller to decide whether "offline mode is ready" will be misled. |
Turn these off before you conclude anything
Because Update on reload and Bypass for network persist, a developer who enabled them last week will see behavior that no user ever sees: no waiting worker, no cached responses. Before debugging an update or caching bug, open the Service workers pane and confirm both boxes are clear.
The Network panel's Disable cache checkbox is a different switch. It maps to Network.setCacheDisabled, which disables the HTTP cache for requests while DevTools is open. It is not the same command as bypassing the service worker, and responses served by your worker from Cache Storage still come from the worker. Older Chromium documentation stated that Disable cache also sends requests past the service worker; verify on your version by looking for (ServiceWorker) in the Size column rather than relying on either claim. The distinction between the HTTP cache and Cache Storage is covered on HTTP Caching & Service Workers.
Anatomy of a registration section¶
Each registration is headed by its scope URL and three header buttons: Network requests, Update and Unregister. Below the header are these fields:
- Source
- The script file name, linked to the Sources panel. If the worker has thrown errors, a red error icon with a count appears next to it; clicking it opens the Console. The sub-line Received date/time is the time the browser received the script response for this version, which tells you when the currently active code was fetched.
- Status
-
A vertical stack of up to three versions, each with its version ID (a number that increments with every new version the browser creates for the registration):
#N activated and is running(orstopped,starting,stopping), with a Stop button while running or Start while stopped.#N waiting to activate, with a skipWaiting button and its own Received time. The button callsskipWaitingthrough CDP (ServiceWorker.skipWaiting), which promotes the waiting worker without your page code being involved.#N trying to install, shown while aninstallhandler is running.#N is redundant, shown when a version has been replaced or failed to install.
- Clients
- Every client the active version controls: window clients show their URL and a focus button that brings that tab or app window to the front; worker clients show
Worker: <url>. An empty Clients field while a page is open means the page is not controlled, usually because it loaded before the worker activated and you never calledclients.claim(), or because the page is outside the scope. - Push, Sync, Periodic sync
- Three text fields with buttons that dispatch functional events to the active worker. Covered in detail below.
- Update Cycle
- A small timeline table with Version, Update Activity and Timeline columns showing the install, wait and activate phases for each version and their durations. Expand a row to see Start time and End time. A long Install bar points to slow precaching; a long Wait bar means old clients kept the previous version alive.
- Routers
- Appears only when the active version registered rules with the Static Routing API (
event.addRoutes()duringinstall, available in Chrome since version 123). It lists each rule with its ID, condition and source, which you can match against the rule IDs shown in the Network panel.
Update, Unregister and Network requests¶
Update calls ServiceWorker.updateRegistration, the DevTools equivalent of registration.update(): it fetches the script, compares it byte-for-byte (including imported scripts), and installs a new version only if something changed. Use it to test your real update path; unlike Update on reload, it respects the waiting phase.
Unregister removes the registration. Per the specification, unregistration takes effect for new navigations; pages that are currently controlled stay controlled until they are closed or navigated. Cache Storage and IndexedDB are not cleared. To start completely fresh, use Clear site data in the Storage pane instead (described below).
Network requests switches to the Network panel with the filter is:service-worker-intercepted applied, showing only requests that went through this worker's fetch handler.
Simulating push messages¶
The Push field is pre-filled with Test push message from DevTools and delivers a push event to the active worker through ServiceWorker.deliverPushMessage. The event is dispatched directly to the worker; no push service, VAPID keys or application server is involved. Whatever you type becomes the message payload, readable with event.data.text().
This has two consequences for how you write your handler. First, event.data.json() throws a SyntaxError on the default text, so a handler that assumes JSON will fail the moment you press the button. Second, because the DevTools path skips encryption and the push service, it cannot surface problems with your subscription, VAPID signature, payload encryption or TTL; test those end to end as described on The Web Push Protocol.
A handler that survives both DevTools test messages and real payloads:
// Parse a push payload defensively: DevTools sends plain text,
// your server sends JSON, and a "tickle" push has no payload at all.
function parsePushPayload(event) {
if (!event.data) {
return { title: "Update available", body: "Open the app to see what's new." };
}
const text = event.data.text();
try {
const json = JSON.parse(text);
if (json && typeof json === "object") return json;
} catch {
// Not JSON: fall through and treat as plain text (DevTools test messages).
}
return { title: "Message", body: text };
}
self.addEventListener("push", (event) => {
const payload = parsePushPayload(event);
// Always show a notification: Chrome requires userVisibleOnly subscriptions
// to display something for every push, and shows its own generic
// notification if you don't.
event.waitUntil(
self.registration
.showNotification(payload.title ?? "Message", {
body: payload.body ?? "",
icon: "/icons/icon-192.png",
badge: "/icons/badge-72.png",
tag: payload.tag, // collapse repeated test pushes into one notification
data: { url: payload.url ?? "/" },
})
.catch((err) => {
// showNotification() rejects with a TypeError when notification
// permission is not "granted"; log it so DevTools shows why nothing appeared.
console.error("showNotification failed:", err);
})
);
});
showNotification() requires the notification permission to be granted. If you have not granted it for the origin, the push event runs but nothing appears; the rejection shows up in the worker's console. Grant it via the site information icon in the address bar (Site settings) before testing. The permission and display rules are on Notifications API and Push Notifications.
Simulating background sync¶
The Sync field (default tag test-tag-from-devtools) dispatches a sync event with the tag you type, through ServiceWorker.dispatchSyncEvent. A detail that is easy to miss: DevTools passes lastChance: true, so event.lastChance is true for every DevTools-triggered sync. If your handler behaves differently on the last attempt (for example, it gives up and notifies the user instead of rejecting so the browser retries), DevTools always exercises that branch, never the retry path. To test retries, register a real sync with registration.sync.register(tag) while Offline is ticked, then untick it: Chromium fires the sync when connectivity returns, and a rejected waitUntil() promise schedules a retry with back-off.
self.addEventListener("sync", (event) => {
if (event.tag !== "outbox") return;
event.waitUntil(
flushOutbox().catch((err) => {
// DevTools-triggered syncs always have lastChance === true.
if (event.lastChance) {
// Final attempt: surface the failure instead of retrying silently.
return self.registration.showNotification("Couldn't send your messages", {
body: "They are saved and will be sent next time you open the app.",
tag: "outbox-failed",
});
}
throw err; // Reject so the browser schedules another attempt.
})
);
});
Background Sync is Chromium-only; see Background Sync for the retry schedule and queueing patterns.
Simulating periodic background sync¶
The Periodic sync field (also defaulting to test-tag-from-devtools) dispatches a periodicsync event through ServiceWorker.dispatchPeriodicSyncEvent. This is the only practical way to test the handler during development: real periodic syncs fire only for installed apps, at a browser-chosen interval tied to site engagement, never more often than the minInterval you registered, and only on a network the browser considers suitable. Type the exact tag you passed to registration.periodicSync.register(); tags are matched as plain strings, and a typo produces an event your handler ignores. The scheduling rules are on Periodic Background Sync.
Service workers from other origins¶
At the bottom of the pane, Service workers from other origins has a See all registrations link to chrome://serviceworker-internals. The pane itself only shows registrations for the inspected page's origin; third-party iframes' workers and other sites' workers live on the internals page.
Storage pane: usage, quota simulation and Clear site data¶
Application › Storage summarizes how much the origin stores and lets you wipe it.
The Usage section shows "X used out of Y storage quota" with a breakdown chart by type (IndexedDB, Cache storage, Service workers, File System, and so on). The quota number is the value navigator.storage.estimate() would return. In Incognito the pane warns "Storage quota is limited in Incognito mode", so never draw conclusions about quota from an Incognito window.
Simulate custom storage quota takes a number in MB and overrides the origin's quota (the CDP command behind it is Storage.overrideQuotaForOrigin). This is the only convenient way to reproduce QuotaExceededError from cache.put(), cache.addAll() or an IndexedDB transaction without actually filling a disk. Set it to a value just above current usage, trigger your precache or a large write, and verify that your error handling and eviction logic work. The underlying limits per browser are on Storage Quotas & Persistence.
Clear site data clears the types checked below it:
- Application: Unregister service workers
- Storage: Local and session storage, IndexedDB, Cookies, Cache storage
- A separate option to include Third-party cookies
With everything checked, this is equivalent to a first visit and is the right reset before testing install, first-run precaching or upgrade paths. The production equivalent, which you can send from a logout endpoint, is the Clear-Site-Data response header; its "storage" directive also unregisters service workers, and "cache" clears the HTTP cache.
Clearing is not the same as a new user
Clearing site data does not uninstall an installed PWA, reset permission decisions (notifications, persistent storage), or remove push subscriptions held by your server. For a genuinely clean state, use a fresh browser profile: --user-data-dir=/tmp/pwa-test-profile on the Chrome command line creates one that you can delete afterwards.
Cache storage viewer¶
Expanding Cache storage lists every cache for the origin (the names your code passed to caches.open()), grouped by storage key. Selecting one shows a table with the columns #, Name, Response-Type, Content-Type, Content-Length, Time Cached and Vary Header. Selecting a row shows the stored response's Headers and a Preview of the body below the table.
The toolbar provides Refresh, Filter by path (a substring filter on the request URL), Delete Selected and a count of "Matching entries" and "Total entries". A right-click on the cache name offers Delete for the whole cache.
What to look for:
- Response-Type
opaqueidentifies no-CORS cross-origin responses. Their body and status are hidden from your code, and Chromium pads their size for quota accounting; the padding is a pseudo-random amount between 0 and about 14 MiB per response, about 7 MiB on average (see Storage Quotas & Persistence). A handful of opaque responses cached "just in case" can consume a surprising share of quota. See Cache Storage API. - Vary Header: rows whose stored response has a
Varyheader show a warning, "Set ignoreVary to true when matching this entry". Cache matching honorsVaryby comparing the listed request headers of the stored request and the lookup request, so a cached response withVary: Accept-EncodingorVary: Originmay never match a request built differently. Either stripVarybefore caching or match with{ ignoreVary: true }. - Time Cached tells you whether an entry was written by the current worker version or survived from an older one, which is how you catch a missing cache cleanup in
activate. - Entries whose name includes a query string (for example
app.js?v=3) are distinct keys; useignoreSearchor normalize URLs if you expected them to match.
The viewer does not live-update. After your code writes to a cache, click Refresh (or reselect the cache) before concluding that the write failed. For anything the viewer cannot show, such as the stored request's headers or a quick size estimate, inspect from the Console:
// Paste into the Console of a page on your origin (or the service worker context).
(async () => {
const report = [];
for (const name of await caches.keys()) {
const cache = await caches.open(name);
const requests = await cache.keys();
let bytes = 0;
for (const req of requests) {
const res = await cache.match(req);
// Opaque responses report 0 here; their real quota cost is padded.
if (res && res.type !== "opaque") bytes += (await res.clone().blob()).size;
}
report.push({ cache: name, entries: requests.length, approxKiB: Math.round(bytes / 1024) });
}
console.table(report);
const { usage, quota } = await navigator.storage.estimate();
console.log(`Origin usage ${(usage / 1048576).toFixed(1)} MiB of ${(quota / 1048576).toFixed(0)} MiB`);
})();
IndexedDB viewer¶
Expanding IndexedDB lists databases per storage key with their Version; each database expands to its object stores and each store to its indexes. The database view has Delete database and Refresh database buttons. The object store view shows Key, Primary key (for index views) and Value columns, with a Filter by key (show keys greater or equal to) box, paging buttons, Delete selected, Clear object store and Refresh. The footer shows Total entries and, for stores with a key generator, Key generator value (the next auto-increment key). Selecting an index sorts the listing by that index's key path.
Two behaviors cause confusion. First, the view does not update in real time; a "Data may be stale" banner appears when DevTools detects modifications, and you must click Refresh. Second, values are read-only in this view; to edit records, run a transaction from the Console. When debugging schema migrations, the connections that matter are the ones held by your other tabs and your service worker: an open() with a higher version fires versionchange on every open connection and reports blocked until they close. If an upgrade stays blocked with a single tab open, look for a connection held by the service worker (which outlives the page), and, to rule out tooling, switch away from the IndexedDB view and retry. More on versioning is on IndexedDB.
Storage buckets¶
Storage buckets lists buckets created with the Storage Buckets API (navigator.storageBuckets.open(), Chromium-only), each with its own IndexedDB, Cache Storage and other storage, persistence, durability, quota and expiration. If your app uses buckets to separate evictable caches from critical data, this is where you confirm which data landed in which bucket and delete a bucket to simulate eviction. The IndexedDB and Cache storage trees also group their entries by bucket.
Background services: recording events over days¶
Some PWA bugs only happen when no DevTools window is open: a periodic sync that fires at 3 a.m., a push that arrives while the laptop is asleep, a background fetch that completes after the tab is closed. The Background services section records these events for you.
Select Background fetch, Background sync, Notifications, Payment handler, Periodic background sync or Push messaging, then click the record button (Start recording events, or press Ctrl+E / Cmd+E). The empty-state text spells out the unusual lifetime: "DevTools will record all X activity for up to 3 days, even when closed." Recording is per profile and per service, and it continues after you close DevTools or the tab.
The event table has the columns Timestamp, Event, Origin, Storage Key, Service Worker Scope and Instance ID. Selecting a row shows its metadata (for example the sync tag, the notification title, or the background fetch ID and progress). The toolbar has Clear, Save events (exports the log to a file you can attach to a bug report), Show events from other domains and Show events from other storage partitions.
Use the recorder to answer questions such as:
- Did the browser ever dispatch the
periodicsyncevent, and at what intervals? (If the Periodic background sync log shows registrations but no dispatches over a day, the app's engagement or install state is not allowing it.) - Did a push arrive, and was a notification shown for it? Compare Push messaging events with Notifications events for the same time window.
- Which instance of a background fetch completed or failed, and when? (Instance ID correlates events for one
backgroundFetch.fetch()call.)
The other entries in the section (Back/forward cache, Bounce tracking mitigations, Speculative loads, Reporting API, Device bound sessions) are not recorders in this sense; they are diagnostic views for their respective features. Back/forward cache is still worth a visit: its Test back/forward cache button tells you whether pages are eligible for bfcache, and an open BroadcastChannel, unload handler or in-progress IndexedDB transaction are common blockers in PWAs.
Frames pane¶
Frames shows the frame tree. Selecting the top frame displays its URL, Origin, Owner element (for iframes), a Security & isolation block (secure context, cross-origin isolation, COOP and COEP status), API availability (for example whether SharedArrayBuffer is available), Permissions Policy (allowed and disabled features), Origin trials tokens with their status, and the document's Content Security Policy.
For PWAs this pane answers three questions quickly:
- Is the context secure? Service workers, push, and most capabilities require it; the pane states it explicitly instead of making you infer it.
- Is a capability blocked by Permissions Policy? If your app runs inside an iframe (a common pattern for embedded widgets), features such as
web-share,clipboard-writeorfullscreenmust be delegated with theallowattribute; the pane lists what is disabled. - Is an origin trial token active? Experimental PWA capabilities are often gated by origin trials; a token that is expired or bound to the wrong origin shows its status here.
The frame tree also lists Opened windows (from window.open()) and dedicated Web Workers per frame. Service workers are not listed under frames; they are separate targets shown in the Service workers pane and in the Sources panel's threads.
Debugging service worker code: Console, Sources and threads¶
A service worker is a separate JavaScript target with its own global scope, but Chromium auto-attaches DevTools to the workers of the page you are inspecting, so you rarely need a second window.
Console. Messages logged by the service worker appear in the page's Console, interleaved with page messages and labeled with the script location (sw.js:42). The execution-context dropdown in the Console toolbar (it reads top by default) lists the service worker; select it to evaluate expressions in the worker's global scope, for example registration.active.state, await caches.keys() or a quick self.clients.matchAll(). Tick Selected context only in the Console settings to hide page noise while you work in the worker.
Sources. The worker script appears in the Page tree under its origin. Breakpoints set in sw.js are keyed by URL and survive reloads and new worker versions. When execution pauses in the worker, the Threads pane shows Main and the service worker, and you can switch between them. Event listener breakpoints and debugger; statements work in the worker as they do on the page.
Idle termination is suspended while DevTools is attached. Chromium normally stops an idle service worker after about 30 seconds, but it does not apply its idle and event timeouts while DevTools is attached to the worker, otherwise every breakpoint would kill it. That makes a whole class of bugs invisible during development: state kept in global variables (an in-memory auth token, a counter, an open IndexedDB connection, a Map used as a cache) survives indefinitely with DevTools open, and is lost in production whenever the browser restarts the worker. Use the Stop button in the Service workers pane after setting up state, then trigger the next event: the worker restarts from a fresh global scope, exactly as it does for users. The lifecycle rules are on Service Worker Lifecycle and the anti-patterns on Pitfalls & Anti-Patterns.
Debugging the install and activate handlers¶
The install handler runs once per version, usually before you have had a chance to set a breakpoint in the new script. Three reliable ways to catch it:
- Breakpoint by URL, then trigger an update. Set the breakpoint in the current
sw.js, change a byte in the script on the server, and click Update in the Service workers pane. The new version'sinstallhandler pauses at the breakpoint because breakpoints are matched by URL and line. debugger;in the handler. With DevTools open for a page on the origin, adebugger;statement ininstallpauses the new version as it starts. Remove it before deploying.- Pause on startup. On
chrome://serviceworker-internals, tick Open DevTools window and pause JavaScript execution on Service Worker startup for debugging. Every service worker in the profile then opens a dedicated DevTools window paused on its first statement whenever it starts, which is the only practical way to debug a worker woken bypushorperiodicsyncwith no page open. Untick it when you are done; it affects all origins.
For lifecycle bugs, instrument the page side as well. The helper below logs every state transition and controller change with timestamps, which, next to the Update Cycle table, shows exactly where an update stalls.
// Development-only helper: import it from your page entry point behind a flag,
// e.g. if (location.search.includes("swdebug")) import("./sw-debug.js");
const t0 = performance.now();
const log = (...args) =>
console.log(`%c[sw ${(performance.now() - t0).toFixed(0)}ms]`, "color:#7c4dff", ...args);
function watchWorker(worker, label) {
if (!worker) return;
log(`${label}: ${worker.scriptURL} is ${worker.state}`);
worker.addEventListener("statechange", () => log(`${label} -> ${worker.state}`));
}
async function main() {
if (!("serviceWorker" in navigator)) {
log("Service workers unsupported (or disabled in this context).");
return;
}
log("controller at load:", navigator.serviceWorker.controller?.scriptURL ?? "none (page is uncontrolled)");
navigator.serviceWorker.addEventListener("controllerchange", () =>
log("controllerchange ->", navigator.serviceWorker.controller?.scriptURL)
);
navigator.serviceWorker.addEventListener("message", (e) => log("message from SW:", e.data));
const reg = await navigator.serviceWorker.getRegistration();
if (!reg) {
log("No registration for this page's URL yet.");
return;
}
log(`registration scope=${reg.scope} updateViaCache=${reg.updateViaCache}`);
watchWorker(reg.installing, "installing");
watchWorker(reg.waiting, "waiting");
watchWorker(reg.active, "active");
reg.addEventListener("updatefound", () => watchWorker(reg.installing, "installing (updatefound)"));
if ("storage" in navigator && navigator.storage.estimate) {
const { usage, quota } = await navigator.storage.estimate();
const persisted = await navigator.storage.persisted?.();
log(`storage ${(usage / 1048576).toFixed(1)} MiB / ${(quota / 1048576).toFixed(0)} MiB, persisted=${persisted}`);
}
}
main().catch((err) => console.error("[sw-debug] failed:", err));
Network panel indicators for service-worker traffic¶
The Network panel is where you prove, rather than assume, which layer answered a request. Chromium records both sides of the service worker: the request your page made (which the worker intercepted) and any request the worker made itself (with fetch() in a handler or during precaching).
| Indicator | Where | Meaning |
|---|---|---|
(ServiceWorker) | Size column; tooltip "Served from ServiceWorker, resource size: N" | The response was provided by the worker's fetch handler via respondWith(), whether it came from Cache Storage, a network fetch inside the worker, or was constructed in code. |
(ServiceWorker router) | Size column; tooltip "Matched to ServiceWorker router#N, resource size: N" | A Static Routing API rule matched (rule N), and the worker's JavaScript may not have run at all. With a network source the tooltip also reports the bytes transferred. |
| "no matching ServiceWorker routes" | Size column tooltip | Router rules exist but none matched; the request went to the network directly or through the fetch handler. |
(disk cache) / (memory cache) / (prefetch cache) | Size column | Not the service worker: the browser's HTTP caches or speculation-rules prefetch answered. |
| Gear icon before the name | Name column | The request was initiated by the service worker itself, for example precaching in install or the fetch() inside a network-first handler. |
is:service-worker-intercepted | Filter box | Shows only requests that went through a worker's fetch handling. |
is:service-worker-initiated | Filter box | Shows only requests the worker made. |
A typical network-first navigation therefore appears twice: once as the page's document request marked (ServiceWorker), and once as a gear-marked request made by the worker. With navigation preload enabled, the worker-side request carries the Service-Worker-Navigation-Preload header (value true unless you changed it with setHeaderValue()), which is how you confirm preload is actually being used rather than a second, redundant fetch.
The Timing tab for intercepted requests¶
Select an intercepted request and open Timing. Besides the usual phases, Chromium shows service-worker-specific ones:
- Startup: time to start the worker if it was not running. Large values here are the cost of a cold worker on navigation, which navigation preload and static routing exist to hide.
- respondWith: time from dispatching
fetchto the promise passed torespondWith()settling. - Request to ServiceWorker: time spent handing the request to the worker.
- Router evaluation and Cache lookup: time spent evaluating Static Routing API conditions and, for a
cachesource, looking up Cache Storage. - Source of response: one of ServiceWorker cache storage, From HTTP cache, Network fetch or Fallback code (a response constructed in JavaScript), plus Cache storage cache name when the response came from a named cache, and Retrieval Time.
- For router matches, Matched source and Actual source, which differ when, for example, a
race-network-and-fetch-handlerrule was won by the fetch handler.
These timings are the right input for deciding whether a handler is too slow; the broader measurement approach is on Measuring Performance.
Disable cache, Bypass for network, Offline and hard reload compared¶
| Action | HTTP cache | Service worker fetch handling | Page controlled afterwards? | Typical use |
|---|---|---|---|---|
| Network › Disable cache (DevTools open) | Bypassed for requests | Not bypassed by this command (see the note above) | Yes | Testing HTTP caching headers |
| Application › Bypass for network | Normal | Skipped for all requests | Yes, controller stays set | Checking whether a bug is in the worker |
| Offline (either panel) | Normal | Runs; network requests inside it fail | Yes | Offline fallbacks and caching strategies |
| Hard reload (Ctrl+Shift+R / Cmd+Shift+R) | Revalidated | Skipped for that navigation and its subresources | No, controller is null until the next normal load | Loading the page as if no worker existed |
| Clear site data then reload | Normal | No worker until registration completes again | Not on first load (unless clients.claim()) | First-visit and install testing |
The hard-reload row surprises people debugging "the service worker isn't working": after Shift-reload the page is deliberately uncontrolled, and a normal reload restores control.
Simulating slow, flaky and partially failing networks¶
The Offline checkbox answers one question: does the app work with no network at all? Most real failures are partial: a connection that stalls, one API host that is down, or a single hashed asset that returns 404 after a deploy. Chromium DevTools has three tools for these cases, and a tiny test server covers what they cannot.
Custom throttling profiles. In the Network panel's throttling menu choose Add… (or open DevTools Settings › Throttling) and create a profile with a very high Latency, for example 8,000 ms, and normal bandwidth. Unlike Offline, requests then hang before failing or succeeding, which is the only way to exercise the timeout branch of a network-first handler (a Workbox networkTimeoutSeconds value, or your own Promise.race() with a timer). Check a worker-initiated request (gear icon) in the log to confirm that the worker's own fetch() calls are being delayed as well, not only the page's.
Request conditions (request blocking). The Request conditions drawer tab (More tools menu, or right-click a request and choose to block its URL or domain) lets you add URL patterns that are blocked; current versions can also throttle individual patterns. A blocked request fails with a network error (net::ERR_BLOCKED_BY_CLIENT in the log), which your worker sees as a rejected fetch() with a TypeError. Use it to take down only /api/*, only a CDN host, or only one precached file, and confirm that the install step fails cleanly (cache.addAll() is atomic) or that a stale-while-revalidate route keeps serving the cached copy.
Force-failing the update check. Block the pattern */sw.js and click Update in the Service workers pane: the update check fails, the existing worker keeps running, and registration.update() called from the Console rejects. This is how you verify that a failed update check never breaks the page that is already controlled.
What DevTools cannot simulate is a server that responds slowly with a specific status, or that fails only every other time. For those, point the app at a small local server:
// Usage: node scripts/flaky-server.mjs ./dist 8080
// Static file server with fault injection for manual and automated PWA tests:
// ?delay=5000 wait 5 s before responding (exercises network timeouts)
// ?status=503 respond with that status instead of the file
// ?flaky=0.5 fail 50% of requests with a dropped connection
// Fault parameters can also be set globally with the FAULT_* environment variables,
// so requests the page does not control (e.g. precache fetches) can be affected too.
import { createServer } from "node:http";
import { readFile, stat } from "node:fs/promises";
import { extname, join, normalize, resolve, sep } from "node:path";
const root = resolve(process.argv[2] ?? "./dist");
const port = Number(process.argv[3] ?? 8080);
const types = {
".html": "text/html; charset=utf-8",
".js": "text/javascript; charset=utf-8",
".mjs": "text/javascript; charset=utf-8",
".css": "text/css; charset=utf-8",
".json": "application/json",
".webmanifest": "application/manifest+json",
".png": "image/png",
".svg": "image/svg+xml",
".webp": "image/webp",
};
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
createServer(async (req, res) => {
const url = new URL(req.url, `http://localhost:${port}`);
const delay = Number(url.searchParams.get("delay") ?? process.env.FAULT_DELAY ?? 0);
const status = Number(url.searchParams.get("status") ?? process.env.FAULT_STATUS ?? 0);
const flaky = Number(url.searchParams.get("flaky") ?? process.env.FAULT_FLAKY ?? 0);
if (delay > 0) await sleep(delay);
if (flaky > 0 && Math.random() < flaky) {
req.socket.destroy(); // The browser sees a network error, not an HTTP status.
return;
}
if (status >= 400) {
res.writeHead(status, { "Content-Type": "text/plain", "Cache-Control": "no-store" });
res.end(`Injected ${status}`);
return;
}
// Resolve the path safely inside root (no ../ traversal).
let file = normalize(join(root, decodeURIComponent(url.pathname)));
if (file !== root && !file.startsWith(root + sep)) {
res.writeHead(403).end();
return;
}
try {
if ((await stat(file)).isDirectory()) file = join(file, "index.html");
const body = await readFile(file);
const headers = { "Content-Type": types[extname(file)] ?? "application/octet-stream" };
// Never let the HTTP cache hide a new service worker script during tests.
if (file.endsWith(`${sep}sw.js`)) headers["Cache-Control"] = "no-cache";
res.writeHead(200, headers).end(body);
} catch {
res.writeHead(404, { "Content-Type": "text/plain" }).end("Not found");
}
}).listen(port, () => console.log(`Serving ${root} on http://localhost:${port}`));
Because the server runs on localhost, the page is a secure context, and through Chrome's port forwarding (described below) the same fault injection works on an Android device.
chrome://serviceworker-internals¶
chrome://serviceworker-internals lists every service worker registration in the current profile, for every origin and every storage partition, grouped under "Registrations in: profile path" (or "Registrations: Incognito"). It is the tool of choice when a third-party iframe's worker, or a worker for a different origin, is involved, and for seeing registrations that DevTools cannot attach to.
For each registration it shows:
- Scope and Registration ID (with "(unregistered)" if it has been unregistered but is still in use).
- Storage key, broken into Origin, Top level site, Ancestor chain bit and, when present, Nonce, whenever third-party storage partitioning is enabled. Two registrations with the same scope but different top-level sites are different registrations; this is how you confirm that your widget's worker inside
partner.exampleis not the same as the one on your own site. See Privacy & Storage Partitioning. - Navigation preload enabled and Navigation preload header length.
- Unregister and, when the active worker is not running, Start.
For each version (active and waiting):
- Installation Status (for example
ACTIVATED) and Running Status (RUNNING,STOPPEDand the transitional states). - Fetch handler existence and Fetch handler type. Chromium classifies fetch handlers and skips dispatching to one it recognizes as a no-op, logging the console warning "Fetch event handler is recognized as no-op. No-op fetch handler may bring overhead during navigation. Consider removing the handler if possible."
- Static router rules, as JSON, when the version registered any.
- Script, Version ID, Renderer process ID, Renderer thread ID and DevTools agent route ID.
- Each controlled Client with its ID and URL.
- A Log text area that collects the worker's console output and errors even when no DevTools window was attached, which is often the only record of why a worker woken by a push failed.
- Stop and Inspect buttons while running (Inspect is disabled when enterprise policy blocks DevTools).
At the top of the page is the Open DevTools window and pause JavaScript execution on Service Worker startup for debugging. checkbox described earlier.
chrome://inspect: workers, Android devices and installed apps¶
chrome://inspect is Chromium's target browser. Its sidebar has Devices, Pages, Extensions, Apps, Shared workers, Service workers, Shared storage worklets, Native UI and Other. The Service workers section (chrome://inspect/#service-workers) lists running workers across all origins with inspect and terminate links. terminate stops the worker process, the same as Stop in the Application panel, and is handy for workers of origins you do not have open.
Remote debugging Android: tabs, WebAPKs and TWAs¶
To debug your PWA on an Android device from a desktop:
- On the device, enable Developer options (tap Build number seven times in the About screen) and turn on USB debugging.
- Connect the device by USB. On the desktop, open
chrome://inspect/#devicesand make sure Discover USB devices is checked. - Accept the Allow USB debugging? prompt on the device. The device first appears as "Offline" until you accept.
- Open your PWA on the device. Each tab and web app window appears under the device's Chrome entry with an inspect link. Installed PWAs on Android (WebAPKs) and Trusted Web Activities run inside the device's Chrome, so their windows appear in the same list as tabs.
- Click inspect. You get the full DevTools, including the Application panel, attached to the device, with a screencast you can interact with (clicks are translated into taps).
On Windows you may need the OEM USB driver for the device; if the authorization prompt never appears, revoke USB debugging authorizations in Developer options and reconnect. The command-line alternative is adb forward tcp:9222 localabstract:chrome_devtools_remote, which exposes the device's DevTools protocol endpoint on the desktop's port 9222.
Port forwarding is the key to testing a local build. Click Port forwarding next to Discover USB devices, add a rule with Device port 8080 and Local address localhost:8080, and tick Enable port forwarding. The device's Chrome can now load http://localhost:8080, served by your laptop over the USB connection. Because localhost is a potentially trustworthy origin, the page is a secure context on the device, so service workers, push subscription and the install prompt all work without HTTPS certificates. Loading the same build as http://192.168.1.20:8080 does not work: a LAN IP over HTTP is not a secure context and navigator.serviceWorker is undefined there.
For the installed app's system-level state on the device, open chrome://webapks in Chrome on Android. For each WebAPK it lists Short name, Package name, Shell APK version, Version code, URI, Scope, Manifest URL, Manifest Start URL, Manifest Id, Display Mode, Orientation, Theme color, Background color, Dark theme color, Dark background color, Last Update Check Time, Last Update Completion Time, Check for Updates Less Frequently, Owning Browser and Update Status, with an Update button per app. Use it to see which manifest values the installed WebAPK was minted with and to request an update after you change icons or the name, instead of waiting for Chrome's periodic update check (normally at most once a day). The update rules are on App Identity & Updates and Android specifics on Android and Trusted Web Activity.
chrome://web-app-internals and other internal pages¶
chrome://web-app-internals dumps the desktop web app system's internal state as JSON. Chromium's own documentation recommends it for inspecting installed-app state and it is what Chromium engineers ask for in bug reports. Its sections include InstalledWebApps (every installed app with its manifest ID, start URL, scope, display mode, install source, OS integration state, file handlers and protocol handlers as Chrome recorded them), PreinstalledWebAppConfigs, UserUninstalledPreinstalledWebAppPrefs, WebAppPreferences, LockManager, CommandManager (recent install, update and uninstall commands with their results), DatabaseLog, IconErrorLog, WebAppDirectoryDiskState, NavigationCapturing, and the Isolated Web App sections. The page also hosts the developer-mode tools for Isolated Web Apps: installing an IWA through a dev-mode proxy or from a signed web bundle, and forcing update checks.
Use it when:
- The installed app ignores a manifest change: compare the recorded values with your current manifest, and check CommandManager for a failed update command.
- An icon looks wrong or blank: IconErrorLog records download and decode failures.
protocol_handlersorfile_handlersdo not register with the OS: the installed-app entry shows what Chrome thinks is registered.
Other internal pages that matter for PWAs:
| Page | What it shows | Use it for |
|---|---|---|
chrome://apps | Installed apps launcher (desktop) | Launching, uninstalling and "open as window" toggles for installed apps |
chrome://webapks (Android) | WebAPK metadata and update status | Checking minted manifest values; forcing an update |
chrome://gcm-internals | State of Chrome's connection to its push messaging backend | Confirming the browser is connected when pushes never arrive |
chrome://quota-internals | Per-origin usage, quota and eviction information | Understanding eviction and quota numbers beyond estimate() |
chrome://indexeddb-internals | IndexedDB instances per storage key, with connection and transaction details | Finding which connection blocks an upgrade |
chrome://site-engagement | Per-origin engagement scores | Understanding why periodic sync or install promotion is not happening |
chrome://net-export | Captures a NetLog file of all network activity | Deep network debugging, including requests made while no DevTools was open |
Firefox: about:debugging, the Application panel and about:serviceworkers¶
Firefox implements service workers, Cache Storage, IndexedDB, Push and Notifications, but not Background Sync, Periodic Background Sync or Background Fetch, so its tooling is simpler. Since Firefox 143 on Windows, Firefox also lets you pin a website to the taskbar as a "web app" that runs in a simplified window (the feature is called Taskbar Tabs in the source and is controlled by the browser.taskbarTabs.enabled preference). These windows are not manifest-driven installs in the Chromium sense and have no dedicated DevTools pane.
The Application panel¶
Firefox DevTools has an Application panel with two sidebar items by default, plus a third behind a preference:
- Manifest: under the heading App Manifest, a link to the manifest JSON (or a note when it is embedded as a data URL) followed by Errors and Warnings, Identity, Presentation and Icons (each icon rendered with its sizes and
Purpose). It is a viewer and linter; Firefox for desktop does not use it to decide installability. - Service Workers: registrations for the current domain with status Running or Stopped, the script URL, an "Updated date" line, an Unregister button, a Start button for stopped workers, and an inspect icon that opens the Debugger on the worker. A link reads "Open about:debugging for Service Workers from other domains".
- Session History (hidden by default): a diagram of the tab's session history entries, useful when debugging SPA navigations and
historymanipulation. It only appears after you setdevtools.application.sessionHistory.enabledtotrueinabout:config; the default isfalse.
Cache Storage lives in the Storage panel (Firefox's Storage Inspector) together with Cookies, Indexed DB, Local Storage and Session Storage. Entries can be deleted from the context menu, per item or per cache. In the Network panel, responses served by a service worker show service worker in the Transferred column, and the Throttling menu includes an Offline option.
For local testing over plain HTTP on a hostname other than localhost, DevTools Settings › Advanced settings has Enable Service Workers over HTTP (when toolbox is open). It applies only to tabs that have the toolbox open, so open DevTools before loading the page.
about:debugging¶
about:debugging#/runtime/this-firefox lists Service Workers, Shared Workers and Other Workers for the whole profile. Each service worker entry shows its Origin, Scope, Push Service endpoint (the push subscription endpoint URL, if subscribed), a Fetch field reading "Listening for fetch events" or "Not listening for fetch events", and a status of Running, Stopped or Registering. Actions are Inspect (opens a toolbox attached to the worker), Start, Push and Unregister.
The Push button dispatches a push event to the worker without a server, like Chrome's, but with no payload field, so event.data is null; the defensive handler shown earlier covers that case. In some process configurations Firefox disables Push, Start and Inspect for service workers and explains why in the button's tooltip. If the page shows "Your browser configuration is not compatible with Service Workers", check dom.serviceWorkers.enabled in about:config.
about:debugging is also how you debug Firefox for Android: enable Developer options and USB debugging on the device, enable Remote debugging via USB in Firefox for Android's settings, connect the cable, click Enable USB Devices in about:debugging on the desktop and connect to the device. Network debug servers can be added under Network Location as host:port.
Firefox's private browsing mode historically disabled service workers entirely. Firefox 140's release notes announce that service workers are now available in private browsing, building on the encrypted storage already used for IndexedDB and the Cache API there, so private windows are now usable for quick clean-state checks. A separate profile (about:profiles) remains the more faithful clean state.
about:serviceworkers¶
about:serviceworkers still exists and shows Registered Service Workers with, for each: Origin, Scope, Script Spec, Current Worker URL, Active Cache Name, Waiting Cache Name and Push Endpoint, plus Update and Unregister buttons. Firefox's own source notes why it survives: about:debugging is not available on mobile, and this page lets you check registrations directly in Firefox for Android without connecting a desktop.
Safari Web Inspector: macOS, iOS, iPadOS and web apps¶
Safari's Web Inspector is the only way to inspect Home Screen web apps on iPhone and iPad, and it is essential because every browser on iOS uses WebKit.
Enabling it¶
- macOS: Safari › Settings… › Advanced › check Show features for web developers. (Before Safari 17 the label read "Show Develop menu in menu bar".) This adds the Develop menu and the Inspect Element context menu item.
- iOS and iPadOS: Settings › Apps › Safari › Advanced › turn on Web Inspector (on releases before iOS 18 the path is Settings › Safari › Advanced). Connect the device to the Mac with a cable and trust the computer when prompted. After the first wired connection you can enable Connect via Network in the device's submenu of the Develop menu to debug over Wi-Fi.
- Simulators: Web Inspector is always enabled for iOS and iPadOS simulators, and booted simulators appear in the Develop menu like devices.
Use a Safari on the Mac that is at least as new as the device's iOS; an older desktop Safari may fail to list or attach to newer devices.
Inspecting pages, service workers and Home Screen web apps¶
On a device, the Develop menu › device name submenu groups inspectable content by app:
- Pages in Safari (and in other apps that made their web views inspectable).
- Service Workers, near the bottom of the submenu, listing workers that are currently running. The section is absent when none are running, so to inspect a worker, first open a page it controls and trigger an event (reload the page) so it is alive.
- Home Screen Web Apps, listing the URL of a web app that is in the foreground. Bring the app to the front on the device, then pick it from the menu. If you switch away on the device, the app is suspended and the inspector loses its target.
On the Mac, running service workers are under Develop › Service Workers. Web apps created with File › Add to Dock (macOS Sonoma and later) run as separate apps; inspect them from Develop › your Mac's name › the web app.
What Web Inspector has, and what it lacks¶
Web Inspector's Network tab marks responses loaded by a service worker as (service worker) with the tooltip "This resource was loaded from a service worker", offers Disable Caches, network throttling presets (3G, Edge and others, enabled with Allow throttling), Preserve Log, HAR import and export, and Local Overrides that can replace or block responses. The Storage tab shows Cookies, Local Storage, Session Storage and Indexed Databases, and the Sources tab debugs page and worker scripts with breakpoints.
Missing compared with Chromium, as of September 2026:
- No manifest viewer. Inspect the parsed result indirectly: check the icon, name and colors after Add to Home Screen, or fetch the manifest in the Console.
- No Cache Storage browser. Use the Console snippet from the Cache storage section above, run in the page or the service worker target.
- No offline toggle. Use airplane mode on the device, turn off Wi-Fi, or use the Network Link Conditioner on the Mac; alternatively a Local Override that blocks requests.
- No push, sync or lifecycle simulation. Push must be tested end to end through Apple's push service with a real subscription; see Web Push on iOS & Safari. Background Sync and Periodic Background Sync do not exist in WebKit.
- No unregister or update buttons. Call
registration.unregister()orregistration.update()from the Console.
To reset state on iOS, clear Safari's data in Settings › Apps › Safari › Advanced › Website Data. Home Screen web apps keep their storage separate from Safari tabs, so removing and re-adding the web app is the dependable way to reset an installed app. Platform behavior and limits are on iOS & iPadOS.
Scripting DevTools features with the Chrome DevTools Protocol¶
Everything the Service workers pane does is a CDP command, so you can put the same simulations into scripts and CI. The relevant commands in the ServiceWorker domain are enable, deliverPushMessage (origin, registrationId, data), dispatchSyncEvent (origin, registrationId, tag, lastChance), dispatchPeriodicSyncEvent (origin, registrationId, tag), skipWaiting, startWorker, stopWorker, stopAllWorkers, unregister, updateRegistration and setForceUpdateOnPageLoad (the Update on reload checkbox). Registration IDs come from the ServiceWorker.workerRegistrationUpdated event after enable. Unlike the DevTools button, the script can pass lastChance: false to exercise your retry path.
// Usage: node scripts/simulate-sw-events.mjs http://localhost:8080/
// Requires: npm i puppeteer
import puppeteer from "puppeteer";
const url = process.argv[2] ?? "http://localhost:8080/";
const origin = new URL(url).origin;
const browser = await puppeteer.launch();
try {
// Grant notification permission so showNotification() succeeds in the push handler.
await browser.defaultBrowserContext().overridePermissions(origin, ["notifications"]);
const page = await browser.newPage();
page.on("console", (msg) => console.log(`[page] ${msg.text()}`));
const client = await page.createCDPSession();
const registrations = new Map(); // scopeURL -> registrationId
client.on("ServiceWorker.workerRegistrationUpdated", ({ registrations: regs }) => {
for (const r of regs) {
if (r.isDeleted) registrations.delete(r.scopeURL);
else registrations.set(r.scopeURL, r.registrationId);
}
});
await client.send("ServiceWorker.enable");
await page.goto(url, { waitUntil: "load" });
// Wait until a worker is active for this page.
const scope = await page.evaluate(async () => (await navigator.serviceWorker.ready).scope);
// The CDP event can arrive slightly after `ready` resolves; poll briefly.
let registrationId;
for (let i = 0; i < 50 && !registrationId; i++) {
registrationId = registrations.get(scope);
if (!registrationId) await new Promise((r) => setTimeout(r, 100));
}
if (!registrationId) throw new Error(`No CDP registration found for scope ${scope}`);
await client.send("ServiceWorker.deliverPushMessage", {
origin,
registrationId,
data: JSON.stringify({ title: "CI push", body: "Delivered via CDP", tag: "ci" }),
});
// lastChance: false exercises the retry branch that the DevTools button never hits.
await client.send("ServiceWorker.dispatchSyncEvent", {
origin,
registrationId,
tag: "outbox",
lastChance: false,
});
console.log(`Dispatched push and sync to ${scope} (registration ${registrationId}).`);
} finally {
await browser.close();
}
Assertions, offline emulation and cross-browser runs belong in a proper test suite; Automated Testing covers Playwright and Puppeteer patterns in depth, and Lighthouse & Auditing covers audits.
Debugging recipes¶
"Users never get the new version"¶
- Open Application › Service workers and clear Update on reload and Bypass for network.
- Deploy a change to
sw.js(or anything it imports) and click Update. If no new version number appears in Status, the browser saw identical bytes: check the Network panel for thesw.jsrequest. A(disk cache)response means your server sends longCache-Controlheaders for the script and your registration usesupdateViaCache: "all"; the default"imports"already bypasses the HTTP cache for the top-level script. - If the new version shows waiting to activate, look at Clients. Every listed client keeps the old version alive. Closing them, or calling
skipWaiting()after user consent, is the only way forward. - Verify your "update available" UI by listening for
updatefoundandstatechange(thesw-debug.jshelper logs both).
The patterns are compared on Updating Service Workers.
"The page isn't controlled on first load"¶
The Clients field is empty and navigator.serviceWorker.controller is null after the first visit. That is the specified behavior: a page loaded before the worker activated stays uncontrolled until its next navigation unless the worker calls clients.claim() in activate. If the page stays uncontrolled on the second load too, check that the page URL is inside the scope shown in the section header, and that you did not reload with Shift, which bypasses the worker for that navigation.
"The offline fallback page doesn't appear"¶
- Tick Offline and reload a page you have not visited.
- In the Network panel, the document request should show
(ServiceWorker). If it shows a failure without that marker, the worker never handled it: check scope and thatfetchhandling is not bypassed. - If it shows
(ServiceWorker)but the browser's offline error appears, the handler resolvedrespondWith()with a network error. Open Cache storage and confirm the fallback URL is cached under exactly the key you match (watch for query strings and theVarywarning). - Click Stop on the worker and repeat. If the fallback now fails, your handler depends on global state that only existed while the worker stayed alive.
Fallback patterns are on Offline UX & Fallbacks.
"It works with DevTools open and fails without it"¶
Three DevTools behaviors differ from production: the worker is not terminated when idle while DevTools is attached, Update on reload skips the waiting phase, and Disable cache changes HTTP caching. Close DevTools, reproduce, and read the worker's Log in chrome://serviceworker-internals, which captures console output even when no DevTools was attached. If a push-woken worker fails, enable the pause-on-startup checkbox there and send a real push.
"Push works from DevTools but not from my server"¶
The DevTools Push button skips the push service, encryption and your VAPID signature. Record Background services › Push messaging and send a push from your server: if no event is logged, the message never reached the browser. Check your server's response from the push service (a 201 means accepted; 404 or 410 means the subscription is gone; 403 points to a VAPID key mismatch), confirm the endpoint in about:debugging on Firefox or in your own subscription storage, and look at chrome://gcm-internals for the connection state. If events are logged but nothing is shown, the handler failed; read the worker's log.
"The app isn't installable"¶
Read Application › Manifest › Installability first; it names the blocking condition. Then check Errors and warnings for icon size mismatches. If the pane shows no manifest at all, filter the Network panel by manifest and inspect the request's status and Content-Type. On a device, use remote debugging: Android Chrome's criteria and prompts differ from desktop's, as described on Installation by Platform.
"QuotaExceededError in production only"¶
Set Storage › Simulate custom storage quota a few MB above the current usage and run your precache or a large IndexedDB write. Confirm that the error is caught, that the worker still installs (or fails deliberately), and that your cleanup evicts old caches. Check Cache storage for opaque responses, which cost far more quota than their size suggests.
Common pitfalls¶
- Leaving Update on reload or Bypass for network enabled. Both persist. They hide the waiting phase and the fetch handler respectively.
- Trusting a hard reload. Shift-reload loads the page uncontrolled; it is not a way to "reload the service worker".
- Assuming Unregister clears caches. It does not; Cache Storage and IndexedDB remain. Use Clear site data.
- Testing state across restarts with DevTools attached. The worker is kept alive, so globals survive. Use Stop.
- Parsing DevTools push payloads as JSON. The default payload is plain text and Firefox's Push button sends none.
- Testing sync retries with the DevTools Sync button. It always sets
lastChancetotrue. - Inspecting quota in Incognito. Quota is deliberately limited there.
- Using a LAN IP on a phone.
http://192.168.x.xis not a secure context; use port forwarding tolocalhostor a trusted HTTPS certificate. - Waiting for a Safari service worker to appear in the Develop menu. Only running workers are listed; trigger an event first.
- Assuming the Cache storage and IndexedDB views are live. Both need Refresh.
Further reading¶
On this site
- Testing & Debugging overview
- Automated Testing
- Lighthouse & Auditing
- Service Worker Lifecycle
- Updating Service Workers
- Cache Storage API
- Storage Quotas & Persistence
- Installability Criteria
External references
- Debug Progressive Web Apps (Chrome for Developers)
- Debug background services (Chrome for Developers)
- View cache data and View and change IndexedDB data (Chrome for Developers)
- Remote debug Android devices and Access local servers (Chrome for Developers)
- Debug a Progressive Web App (Microsoft Edge documentation)
- Chrome DevTools Protocol: ServiceWorker domain
- Service Worker debugging FAQ (Chromium)
- about:debugging and Debugging service workers (Firefox Source Docs)
- Inspecting iOS and iPadOS and Enabling features for web developers (Apple Developer)