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

// 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));

Steps

  1. 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 waiting while 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.

  2. Step 2.

    Deploy the same bytes again and count updatefound. The script rewrites sw.js with identical content, calls update(), 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 no updatefound. 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 name app-v1.

  3. Step 3.

    Deploy version 3, which calls skipWaiting() during install and clients.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 1

    waiting never held anything: skipWaiting() took version 3 from installed to activating, and clients.claim() moved the open tab onto it, firing one controllerchange. The tab was never reloaded. Its document came from version 1, and app-v1 has been deleted underneath it, so the page now runs against a cache built for another version.

  4. 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

24 hours Source: https://developer.mozilla.org/en-US/docs/Web/API/ServiceWorkerRegistration/update

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

Sign: Closing the tab and opening a new one is treated as enough to activate the waiting worker.Cause: Move 4 of step 1 did exactly that and version 1 was still active. The replacement client starts before the old one is gone, so the scope is never empty. Move 5 left it empty for three seconds and version 2 took over. A hard reload does not help either: it bypasses the HTTP cache for the page, not the worker lifecycle.
Sign: A deploy is called shipped because sw.js on the server changed.Cause: Step 2 rewrote the file with the same bytes and a new timestamp. update() fired no updatefound at all. Timestamps, ETags and file sizes are not consulted: the browser compares the script bytes with the running worker.
Sign: A failed update is expected to show up in the registration.Cause: Step 4 shows before and after readings that are identical, down to the controller. A worker that fails install goes redundant and leaves the three slots as they were. The console 404 is the only signal, so capture console output in the check.

What to check next

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.

intermediate9 minpublished updated Maks Verny