How to check what a service worker has cached

Open a page the worker controls and call caches.keys(), then keys() on each cache. In this capture version 1 held 4 entries and 518 bytes. After a deploy both app-v1 and app-v2 were present, because the new worker used a new cache name and deleted nothing.

Why check this

Run this before an offline sign-off, and after any deploy that changes the precache list. Cache Storage is the only copy of the site a controlled page sees, so its contents are the build the user is running.

Two defects live here. The first is a stale entry: a file changes on the server, a cache first worker keeps answering from its own copy, and the tester sees last week's data on a page that reloads cleanly. The second is an orphan cache: a new worker writes app-v2 and never deletes app-v1, so every release leaves one more full copy of the site on disk.

Prerequisites

Steps

  1. Step 1.

    List every cache, every entry in it, and the bytes behind them, before and after a deploy that renames the cache.

    // sw-cache.mjs
    import { copyFileSync } from 'node:fs';
    import { open } from './session.mjs';
    const url = process.argv[2];
    const dump = async (p, label) => {
      const { list, est } = await p.evaluate(async () => {
        const list = [];
        for (const name of await caches.keys()) {
          const c = await caches.open(name);
          const rows = [];
          let total = 0;
          for (const req of await c.keys()) {
            const res = await c.match(req);
            const bytes = (await res.clone().arrayBuffer()).byteLength;
            total += bytes;
            rows.push(`${new URL(req.url).pathname.padEnd(13)} ${res.status} ${
              (res.headers.get('content-type') || '').split(';')[0].padEnd(22)} ${bytes} B`);
          }
          list.push({ name, n: rows.length, total, rows });
        }
        return { list, est: await navigator.storage.estimate() };
      });
      console.log(label);
      for (const c of list) {
        console.log(`  cache ${c.name}   ${c.n} entries   ${c.total} B`);
        for (const r of c.rows) console.log('    ' + r);
      }
      console.log(`  storage.estimate()  usage ${est.usage} B   quota ${est.quota} B`);
    };
    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 dump(s.page, 'after version 1 installed');
    
      copyFileSync('sw-v2.js', 'sw.js');               // new cache name, no cleanup
      const p2 = await s.browser.newPage();
      await p2.goto('about:blank');
      await s.page.close();
      await new Promise((r) => setTimeout(r, 3000));
      await p2.goto(url, { waitUntil: 'networkidle2' });
      await p2.evaluate(() => new Promise((r) => setTimeout(r, 2000)));
      await p2.goto(url, { waitUntil: 'networkidle2' });
      await dump(p2, 'after version 2 took over');
    } finally {
      await s.close();
    }
    
    node sw-cache.mjs http://localhost:8613/
    
    after version 1 installed
    cache app-v1   4 entries   518 B
      /             200 text/html              300 B
      /app.css      200 text/css               40 B
      /data.json    200 application/json       63 B
      /logo.svg     200 image/svg+xml          115 B
    storage.estimate()  usage 3857 B   quota 10737422097 B
    after version 2 took over
    cache app-v1   4 entries   518 B
      /             200 text/html              300 B
      /app.css      200 text/css               40 B
      /data.json    200 application/json       63 B
      /logo.svg     200 image/svg+xml          115 B
    cache app-v2   5 entries   640 B
      /             200 text/html              300 B
      /app.css      200 text/css               40 B
      /data.json    200 application/json       63 B
      /logo.svg     200 image/svg+xml          115 B
      /about.html   200 text/html              122 B
    storage.estimate()  usage 7846 B   quota 10737426086 B

    Version 2 is serving and app-v1 is still on disk with all four entries, because sw-v2.js has no activate handler that deletes old names. The byte figures do not agree with each other either: 518 bytes of response bodies were reported as 3857 bytes of usage, and after the second cache the totals were 1158 bytes of bodies against 7846 bytes of usage. The quota moved with it, from 10737422097 to 10737426086, by the same 3989 bytes that usage grew.

  2. Step 2.

    Change one file on the server without touching the worker, then read the same URL three ways from a controlled page.

    // sw-stale.mjs
    import { copyFileSync, writeFileSync, readFileSync } from 'node:fs';
    import { open } from './session.mjs';
    const url = process.argv[2].replace(/\/$/, '');
    const ORIGINAL = '{"items":[{"sku":"A-1","price":9.5},{"sku":"A-2","price":12}]}\n';
    copyFileSync('sw-v1.js', 'sw.js');
    writeFileSync('data.json', ORIGINAL);
    const s = await open();
    try {
      await s.goto(url + '/');
      await s.page.evaluate(() => navigator.serviceWorker.ready);
      await s.goto(url + '/');
    
      writeFileSync('data.json', '{"items":[{"sku":"A-1","price":9.5},{"sku":"A-2","price":13}]}\n');
      console.log('server file          ', readFileSync('data.json', 'utf8').trim());
      const r = await s.page.evaluate(async () => ({
        viaSw: (await (await fetch('/data.json')).text()).trim(),
        viaQuery: (await (await fetch('/data.json?v=2')).text()).trim(),
        inCache: (await (await caches.match('/data.json')).text()).trim(),
      }));
      console.log('fetch /data.json     ', r.viaSw);
      console.log('fetch /data.json?v=2 ', r.viaQuery);
      console.log('caches.match entry   ', r.inCache);
    } finally {
      writeFileSync('data.json', ORIGINAL);
      await s.close();
    }
    
    node sw-stale.mjs http://localhost:8613/
    
    server file           {"items":[{"sku":"A-1","price":9.5},{"sku":"A-2","price":13}]}
    fetch /data.json      {"items":[{"sku":"A-1","price":9.5},{"sku":"A-2","price":12}]}
    fetch /data.json?v=2  {"items":[{"sku":"A-1","price":9.5},{"sku":"A-2","price":13}]}
    caches.match entry    {"items":[{"sku":"A-1","price":9.5},{"sku":"A-2","price":12}]}

    The server holds price 13 and the page reads price 12. The worker matched the request against its cache and answered from there, and the server was never asked. Adding ?v=2 produced a request the cache has no entry for, which fell through to the network and returned 13. That one line separates "the deploy did not land" from "the deploy landed and the cache is answering".

  3. Step 3.

    Delete the orphan cache and measure what it was holding.

    // sw-cleanup.mjs
    import { copyFileSync } from 'node:fs';
    import { open } from './session.mjs';
    const url = process.argv[2];
    const line = async (p, label) => {
      const r = await p.evaluate(async () => {
        const est = await navigator.storage.estimate();
        return `${(await caches.keys()).join(' ')}   usage ${est.usage} B`;
      });
      console.log(label.padEnd(22), r);
    };
    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);
      copyFileSync('sw-v2.js', 'sw.js');
      const p2 = await s.browser.newPage();
      await p2.goto('about:blank');
      await s.page.close();
      await new Promise((r) => setTimeout(r, 3000));
      await p2.goto(url, { waitUntil: 'networkidle2' });
      await p2.evaluate(() => new Promise((r) => setTimeout(r, 2000)));
      await line(p2, 'version 2 live');
      const deleted = await p2.evaluate(() => caches.delete('app-v1'));
      console.log('caches.delete("app-v1")'.padEnd(22), deleted);
      await line(p2, 'after the delete');
    } finally {
      await s.close();
    }
    
    node sw-cleanup.mjs http://localhost:8613/
    
    version 2 live         app-v1 app-v2   usage 7846 B
    caches.delete("app-v1") true
    after the delete       app-v2   usage 4518 B

    caches.delete() returned true, the name disappeared, and usage fell from 7846 to 4518 bytes. The orphan was costing 3328 bytes for 518 bytes of response bodies. In the worker this belongs in the activate handler, which runs once the new version takes over and is the only moment at which the old cache is certainly unused.

How to read the result

| What you see | What it means | What to do | | --- | --- | --- | | caches.keys() returns nothing on a loaded page | The page is not controlled, or install never finished | Reload once, then read How to check if a service worker is registered. | | More than one cache name | An old version's cache was never deleted | Delete the stale names in activate, not in install. | | An entry whose body differs from the server | Cache first is answering, the deploy did land | Bump the cache name, or make that request network first. | | A URL missing from every cache | The worker falls through to the network for it | Fine when online. It is the list of things that break offline. | | usage far above the sum of the entries | The browser's own per entry overhead | Size a quota budget from estimate(), never from file sizes. |

Common mistakes

Sign: The cache names in the Application panel are read as what the running worker uses.Cause: Step 1 shows app-v1 and app-v2 side by side with the same four files in both. A name in the list proves a worker wrote it once, not that anything reads it now. Only the CACHE constant in the active worker says which one is live.
Sign: Cache size is worked out by adding up the files.Cause: The same capture reported 518 bytes of response bodies as 3857 bytes of usage, and 1158 bytes as 7846. Cache Storage keeps headers and per entry structures alongside each body. At small entry sizes the overhead is larger than the content.
Sign: A quota figure from estimate() is treated as a fixed limit.Cause: quota moved from 10737422097 to 10737426086 bytes between the two reads in step 1, growing by exactly the 3989 bytes usage gained. It is a computed allowance over free disk, not a constant, and it differs per machine and per origin.

What to check next

FAQ

How do I check service worker cache storage in Chrome?

Open DevTools, Application panel, Cache storage in the left tree. It lists every cache name for the origin and the entries in each, with the response headers. The console reading in step 1 returns the same data and adds the byte count per entry, which the panel does not total.

What is the storage limit for a service worker?

There is no separate limit. Cache Storage shares the origin quota with IndexedDB and the rest. navigator.storage.estimate() reported a quota of 10737422097 bytes, close to 10 GB, on one machine on 2026-09-11. The figure is derived from free disk space and changes between machines.

How do I clear a service worker cache?

Call caches.delete(name) from a page on the origin, which returned true and freed 3328 bytes in step 3. DevTools, Application panel, Storage, "Clear site data" removes the caches, the registration and the other origin storage together.

Why does the page still show old data after a deploy?

Because a cache first worker answers from its own copy without asking the server, which step 2 reproduces with a one character change. Check caches.match(url) against the file on the server. The fix is a new cache name per release, or network first for data requests.

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.

intermediate8 minpublished updated Maks Verny