How to check if service worker is updated
Deploy the new worker, reload, and read the registration. waiting holds the new version while active still holds the old one, which keeps serving until every tab in the scope has closed. A build that emits a byte identical sw.js publishes no update at all.
Why check this
Run this after every deploy of a site that registers a service worker, and whenever a fix is reported as not shipped. The worker is the part of a deploy that does not arrive with the next reload.
The failure it catches is a deploy that reaches the server and not the user. The new worker installs, parks in waiting, and version 1 keeps answering from its own cache. The tester reloads twice, sees last week's bundle, and closes the bug as not reproducible. Two neighbouring failures live in the same place: a byte identical build publishes nothing, and a failed install leaves the registration reading as healthy.
Prerequisites
- The local site,
server.mjs,sw-v1.js,session.mjsandprobe.mjsfrom How to check if a service worker is registered, running asnode server.mjs 8613. - ServiceWorkerRegistration.update() and skipWaiting().
- Two more worker versions in the same directory. A deploy is
copy sw-v2.js sw.js.
// sw-v2.js. Same shape as version 1, new cache name, one more asset.
const CACHE = 'app-v2';
const ASSETS = ['/', '/app.css', '/data.json', '/logo.svg', '/about.html'];
self.addEventListener('install', (e) => {
e.waitUntil(caches.open(CACHE).then((c) => c.addAll(ASSETS)));
});
self.addEventListener('fetch', (e) => {
e.respondWith(caches.match(e.request).then((hit) => hit || fetch(e.request)));
});
self.addEventListener('message', (e) => e.ports[0] && e.ports[0].postMessage(CACHE));
// sw-v3.js. The same worker with the two overrides that skip the queue.
const CACHE = 'app-v3';
const ASSETS = ['/', '/app.css', '/data.json', '/logo.svg', '/about.html'];
self.addEventListener('install', (e) => {
self.skipWaiting();
e.waitUntil(caches.open(CACHE).then((c) => c.addAll(ASSETS)));
});
self.addEventListener('activate', (e) => {
e.waitUntil((async () => {
for (const k of await caches.keys()) if (k !== CACHE) await caches.delete(k);
await self.clients.claim();
})());
});
self.addEventListener('fetch', (e) => {
e.respondWith(caches.match(e.request).then((hit) => hit || fetch(e.request)));
});
self.addEventListener('message', (e) => e.ports[0] && e.ports[0].postMessage(CACHE));
sw-broken.jsissw-v1.jswith/missing.pngadded toASSETS, a path the server answers with 404.- Every figure below is one capture on one machine, Chrome 152.0.7977.76 on 2026-09-11.
Steps
- Step 1.
Deploy version 2 under a live version 1 and follow the registration through five moves: two reloads, a tab closed and reopened at once, and a scope left empty for three seconds.
// sw-update.mjs import { copyFileSync } from 'node:fs'; import { open } from './session.mjs'; import { states, show } from './probe.mjs'; const url = process.argv[2]; const settle = (p) => p.evaluate(() => new Promise((r) => setTimeout(r, 1500))); copyFileSync('sw-v1.js', 'sw.js'); const s = await open(); try { await s.goto(url); await s.page.evaluate(() => navigator.serviceWorker.ready); await s.goto(url); show('1. version 1 is live, one tab open', await states(s.page)); copyFileSync('sw-v2.js', 'sw.js'); // the deploy await s.goto(url); await settle(s.page); show('2. deployed version 2, reloaded that tab', await states(s.page)); await s.goto(url); await settle(s.page); show('3. reloaded the same tab again', await states(s.page)); const p2 = await s.browser.newPage(); await p2.goto('about:blank'); await s.page.close(); await p2.goto(url, { waitUntil: 'networkidle2' }); // reopened at once await settle(p2); show('4. tab closed and reopened at once', await states(p2)); const p3 = await s.browser.newPage(); await p3.goto('about:blank'); await p2.close(); await new Promise((r) => setTimeout(r, 3000)); // no tab in scope for 3 s await p3.goto(url, { waitUntil: 'networkidle2' }); await settle(p3); show('5. no tab in scope for 3 s, then reopened', await states(p3)); } finally { await s.close(); }node sw-update.mjs http://localhost:8613/1. version 1 is live, one tab open scope http://localhost:8613/ installing null waiting null active activated (app-v1) controller activated (app-v1) 2. deployed version 2, reloaded that tab scope http://localhost:8613/ installing null waiting installed (app-v2) active activated (app-v1) controller activated (app-v1) 3. reloaded the same tab again scope http://localhost:8613/ installing null waiting installed (app-v2) active activated (app-v1) controller activated (app-v1) 4. tab closed and reopened at once scope http://localhost:8613/ installing null waiting installed (app-v2) active activated (app-v1) controller activated (app-v1) 5. no tab in scope for 3 s, then reopened scope http://localhost:8613/ installing null waiting null active activated (app-v2) controller activated (app-v2)Move 2 is the one to read. Version 2 installed and sits in
waitingwhile version 1 keeps serving. Move 3 shows a reload does not help. Move 4 is the surprise: the tab was closed and reopened, and version 1 was still active, because the two clients overlapped while the new one started. Only move 5, with no client in the scope for three seconds, released the waiting worker. - Step 2.
Deploy the same bytes again and count
updatefound. The script rewritessw.jswith identical content, callsupdate(), then appends one comment line and repeats.// sw-identical.mjs import { copyFileSync, readFileSync, writeFileSync, statSync, appendFileSync } from 'node:fs'; import { createHash } from 'node:crypto'; import { open } from './session.mjs'; import { states } from './probe.mjs'; const url = process.argv[2]; const stamp = () => { const b = readFileSync('sw.js'); const t = statSync('sw.js').mtime.toISOString().slice(11, 19); console.log(` sw.js sha256 ${createHash('sha256').update(b).digest('hex').slice(0, 16)} ${b.length} bytes mtime ${t}`); }; const check = async (page) => { await page.evaluate(() => { window.__uf = 0; return window.__reg.update(); }); await page.evaluate(() => new Promise((r) => setTimeout(r, 3500))); const uf = await page.evaluate(() => window.__uf); const st = await states(page); console.log(` updatefound events ${uf} installing ${st.installing} waiting ${st.waiting} active ${st.active}`); }; copyFileSync('sw-v1.js', 'sw.js'); const s = await open(); try { await s.goto(url); await s.page.evaluate(() => navigator.serviceWorker.ready); await s.goto(url); await s.page.evaluate(async () => { window.__reg = await navigator.serviceWorker.getRegistration(); window.__reg.addEventListener('updatefound', () => window.__uf++); }); console.log('start'); stamp(); console.log('redeploy, same bytes, new file time'); writeFileSync('sw.js', readFileSync('sw-v1.js')); stamp(); await check(s.page); console.log('redeploy, one comment line appended'); appendFileSync('sw.js', '// build 2026-09-11T23:00\n'); stamp(); await check(s.page); } finally { await s.close(); }node sw-identical.mjs http://localhost:8613/start sw.js sha256 168f7d7aab319277 529 bytes mtime 20:31:05 redeploy, same bytes, new file time sw.js sha256 168f7d7aab319277 529 bytes mtime 20:37:50 updatefound events 0 installing null waiting null active activated (app-v1) redeploy, one comment line appended sw.js sha256 2b8fe4010944df34 555 bytes mtime 20:37:55 updatefound events 1 installing null waiting installed (app-v1) active activated (app-v1)The first redeploy changed the file time and nothing else.
update()fetched the script, compared it byte for byte with the running one, and fired noupdatefound. The second added 26 bytes of comment, which changes no behaviour, and the browser installed it as a new worker. The comparison is on script bytes alone, so the waiting worker still reports the cache nameapp-v1. - Step 3.
Deploy version 3, which calls
skipWaiting()during install andclients.claim()during activate, and read the same tab without reloading it.// sw-claim.mjs import { copyFileSync } from 'node:fs'; import { open } from './session.mjs'; import { states, show } from './probe.mjs'; const url = process.argv[2]; const caches_ = (p) => p.evaluate(() => caches.keys()); const s = await open(); try { copyFileSync('sw-v1.js', 'sw.js'); await s.goto(url); await s.page.evaluate(() => navigator.serviceWorker.ready); await s.goto(url); await s.page.evaluate(async () => { window.__cc = 0; navigator.serviceWorker.addEventListener('controllerchange', () => window.__cc++); window.__reg = await navigator.serviceWorker.getRegistration(); }); show('before', await states(s.page)); console.log(' caches ', (await caches_(s.page)).join(' ')); copyFileSync('sw-v3.js', 'sw.js'); // skipWaiting + clients.claim await s.page.evaluate(() => window.__reg.update()); await s.page.evaluate(() => new Promise((r) => setTimeout(r, 3000))); show('after, same tab, no reload', await states(s.page)); console.log(' caches ', (await caches_(s.page)).join(' ')); console.log(' controllerchange events', await s.page.evaluate(() => window.__cc)); } finally { await s.close(); }node sw-claim.mjs http://localhost:8613/before scope http://localhost:8613/ installing null waiting null active activated (app-v1) controller activated (app-v1) caches app-v1 after, same tab, no reload scope http://localhost:8613/ installing null waiting null active activated (app-v3) controller activated (app-v3) caches app-v3 controllerchange events 1waitingnever held anything:skipWaiting()took version 3 from installed to activating, andclients.claim()moved the open tab onto it, firing onecontrollerchange. The tab was never reloaded. Its document came from version 1, andapp-v1has been deleted underneath it, so the page now runs against a cache built for another version. - Step 4.
Deploy a worker with one 404 in its precache list over a live version 1, and read the registration the way a test would.
// sw-baddeploy.mjs import { copyFileSync } from 'node:fs'; import { open } from './session.mjs'; import { states, show } from './probe.mjs'; const url = process.argv[2]; copyFileSync('sw-v1.js', 'sw.js'); const s = await open(); try { await s.goto(url); await s.page.evaluate(() => navigator.serviceWorker.ready); await s.goto(url); show('before the deploy', await states(s.page)); copyFileSync('sw-broken.js', 'sw.js'); // precache list gained a 404 await s.goto(url); await s.page.evaluate(() => new Promise((r) => setTimeout(r, 4000))); show('after the deploy and a reload', await states(s.page)); for (const m of s.console) console.log(' console ', m); } finally { await s.close(); }node sw-baddeploy.mjs http://localhost:8613/before the deploy scope http://localhost:8613/ installing null waiting null active activated (app-v1) controller activated (app-v1) after the deploy and a reload scope http://localhost:8613/ installing null waiting null active activated (app-v1) controller activated (app-v1) console error: Failed to load resource: the server responded with a status of 404 (Not Found)The two readings are identical. The new worker failed on the 404 and went redundant, leaving no trace in the three slots. The only evidence of the deploy is the 404 line in the console, so a check that asserts on the registration alone passes it.
How to read the result
| What you see | What it means | What to do |
| --- | --- | --- |
| waiting installed beside active activated | The new worker is ready and parked | Close every tab in the scope, or ship skipWaiting(). |
| waiting unchanged after a tab was closed and reopened | The old and new client overlapped | Leave the scope with no open tab for a moment, then reopen. |
| updatefound events 0 after a deploy | The script is byte identical to the running one | Check the build actually changed sw.js. A version constant in the file is enough. |
| All three slots unchanged, a 404 in the console | The new worker failed install and went redundant | Fix the precache list. The old version keeps serving until it works. |
| controllerchange with no reload | skipWaiting() and clients.claim() are in the worker | Confirm the open page survives a worker swap mid session. |
Thresholds
The fetch of the worker script bypasses the HTTP cache when the previous fetch was over 24 hours ago. Inside that window a Cache-Control header on sw.js can hold the old script, so the server above sends no-store.
Common mistakes
What to check next
- How to check if a service worker is registered: the four states, and which one serves requests.
- How to check what a service worker has cached: what the old version left behind after this handover.
- How to test offline mode in chrome devtools: run it after the new worker is active.
- How to check cache-control header: the header that can hold
sw.jsin the browser cache. - How to check session storage in chrome: per-tab state, which a worker swap leaves alone.
FAQ
How do I force a service worker to check for an update?
Call registration.update(). It refetches the script and installs it when the bytes differ, which is what step 2 counts. Chrome also checks on every navigation inside the scope.
When and how does a PWA update itself?
The browser refetches the worker script on navigation and on update(). A different script installs as a new worker and waits. It activates when the scope has no client, or at once if the worker calls skipWaiting(). Assets update only when the new worker caches them.
How do I force a service worker to update on reload?
Tick "Update on reload" in the DevTools Service workers pane. It registers and activates the new worker on every load of that tab. In production the equivalent is self.skipWaiting() in install, and step 3 shows what that does to an open tab.
Verified
Verified by Maks VernyChrome 152.0.7977.76Node 22.23.2puppeteer-core 25.10.0
Each output block is what the command above it printed on that date, on the host named in the step. Figures read from a live site move between runs. Compare the shape of the answer rather than the digits, and see the methodology for how a page is re-verified.
Related on this site
intermediate9 minpublished updated Maks Verny