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
- The local site,
sw-v1.js,sw-v2.js,server.mjsandsession.mjsfrom How to check if a service worker is registered, running asnode server.mjs 8613. - CacheStorage for
keys()andmatch(), and StorageManager.estimate() for the byte figures. - The worker must control the page before
cachesshows anything useful. A first visit is uncontrolled, so every script below loads the page twice. - Every figure below is one capture on one machine, Chrome 152.0.7977.76 on 2026-09-11. Byte counts move with the browser's own storage overhead.
Steps
- 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 BVersion 2 is serving and
app-v1is still on disk with all four entries, becausesw-v2.jshas noactivatehandler 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. - 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=2produced 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". - 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 Bcaches.delete()returnedtrue, 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 theactivatehandler, 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
What to check next
- How to check if a service worker is registered: confirm the page is controlled before trusting a cache read.
- How to check if service worker is updated: why the old cache is still there after a deploy.
- How to test offline mode in chrome devtools: the test that fails on whatever the cache is missing.
- How to check localstorage size: the other origin storage counted by the same quota.
- How to check cache-control header: the HTTP cache, which sits behind the worker and answers separately.
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.
Related on this site
intermediate8 minpublished updated Maks Verny