How to check if a service worker is registered
Open the page and call navigator.serviceWorker.getRegistration(). It returns a registration with three worker slots: installing, waiting and active. A registration can exist while active is null. The worker answers requests only once navigator.serviceWorker.controller is set, which takes a second load.
Why check this
Run this after any deploy that ships a service worker, and before an offline test on staging. "Is it registered" has four answers, and three of them mean the worker is not serving anything yet.
The defect it catches is a registration that exists and delivers nothing. One 404 in the precache list makes cache.addAll() reject, install fails, and the registration is thrown away. Online the page still works, because the network is still there. The offline path was never active.
Prerequisites
- Chrome 120 or later and Node 22.
npm i puppeteer-coreinstalls the driver only. It drives the Chrome already on the machine. - A secure context. Registration is refused on plain HTTP, and http://localhost counts as a secure context, so no certificate is needed here.
- ServiceWorkerRegistration documents the three worker slots, and getRegistration() the lookup.
- A local site with its own worker. Put these files in one directory and run
node server.mjs 8613. Stop it by the PID thatnetstat -ano | findstr :8613reports, never by image name.
// server.mjs
import { createServer } from 'node:http';
import { readFile } from 'node:fs/promises';
const port = Number(process.argv[2] || 8613);
const TYPES = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8',
'.css': 'text/css; charset=utf-8', '.json': 'application/json', '.svg': 'image/svg+xml' };
createServer(async (req, res) => {
let path = req.url.split('?')[0];
if (path === '/') path = '/index.html';
const ext = path.slice(path.lastIndexOf('.'));
try {
const body = await readFile(new URL('.' + path, import.meta.url));
res.writeHead(200, { 'content-type': TYPES[ext] ?? 'application/octet-stream',
'cache-control': 'no-store' });
res.end(body);
} catch {
res.writeHead(404, { 'content-type': 'text/plain' });
res.end('not found');
}
}).listen(port, () => console.log('serving on http://localhost:' + port + '/'));
<!-- index.html. app.css, data.json and logo.svg are any three small files. -->
<!doctype html>
<html lang="en">
<head><meta charset="utf-8"><title>Shop</title><link rel="stylesheet" href="/app.css"></head>
<body>
<h1>Shop</h1>
<p id="build">build: 1</p>
<script>
if ('serviceWorker' in navigator) navigator.serviceWorker.register('/sw.js');
</script>
</body>
</html>
// sw-v1.js. Copy it over sw.js to deploy it.
const CACHE = 'app-v1';
const ASSETS = ['/', '/app.css', '/data.json', '/logo.svg'];
self.addEventListener('install', (e) => {
e.waitUntil((async () => {
await new Promise((r) => setTimeout(r, 1500)); // deliberate, so "installing" is readable
await (await caches.open(CACHE)).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));
- Two more copies of that worker.
sw-v2.jssetsCACHEtoapp-v2, adds/about.htmltoASSETSand drops the delay.sw-broken.jsadds/missing.png, a path the server answers with 404. - The launcher and the reader, used by every script below.
// session.mjs
import { launch } from 'puppeteer-core';
import { mkdtempSync, rmSync, existsSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
const CHROME = [
'C:/Program Files/Google/Chrome/Application/chrome.exe',
'C:/Program Files (x86)/Google/Chrome/Application/chrome.exe',
].find((p) => existsSync(p));
if (!CHROME) throw new Error('Chrome is not installed at either standard path.');
export async function open({ headless = true, width = 1280, height = 800, args = [] } = {}) {
const profile = mkdtempSync(join(tmpdir(), 'h2c-chrome-'));
const browser = await launch({
executablePath: CHROME, headless, userDataDir: profile,
defaultViewport: { width, height },
args: ['--remote-debugging-port=0', '--no-first-run', '--no-default-browser-check', ...args],
});
const page = (await browser.pages())[0] ?? (await browser.newPage());
const messages = [];
page.on('console', (m) => messages.push(`${m.type()}: ${m.text()}`));
page.on('pageerror', (e) => messages.push(`pageerror: ${e.message}`));
return {
browser, page, console: messages,
goto: (url, opts) => page.goto(url, { waitUntil: 'networkidle2', timeout: 30000, ...opts }),
async close() {
await browser.close().catch(() => {});
rmSync(profile, { recursive: true, force: true });
},
};
}
// probe.mjs. Each worker answers a MessageChannel port with its own cache name,
// which is the only way to tell two versions apart: scriptURL is /sw.js for all of them.
export async function states(page) {
return page.evaluate(async () => {
const ask = (w) =>
new Promise((res) => {
const ch = new MessageChannel();
ch.port1.onmessage = (e) => res(e.data);
w.postMessage('version', [ch.port2]);
setTimeout(() => res('no answer'), 700);
});
const one = async (w) => (w ? `${w.state} (${await ask(w)})` : 'null');
const reg = await navigator.serviceWorker.getRegistration();
if (!reg) return { registration: 'null' };
return {
scope: reg.scope,
installing: await one(reg.installing),
waiting: await one(reg.waiting),
active: await one(reg.active),
controller: await one(navigator.serviceWorker.controller),
};
});
}
export function show(label, s) {
console.log(label);
for (const [k, v] of Object.entries(s)) console.log(' ' + k.padEnd(12), v);
}
- Every figure below is one capture on one machine, Chrome 152.0.7977.76 on 2026-09-11. Timings move between Chrome versions.
Steps
- Step 1.
Read the registration at four moments of one visit. The script deploys version 1, opens one tab, and prints the three slots and the controller after each move.
// sw-states.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'); // start from a known deploy const s = await open(); try { console.log('Chrome', await s.browser.version()); await s.goto(url); // the page registers /sw.js itself show('A. page loaded, install still running', await states(s.page)); await s.page.evaluate(() => navigator.serviceWorker.ready); show('B. navigator.serviceWorker.ready resolved', await states(s.page)); await s.goto(url); show('C. after one reload', await states(s.page)); copyFileSync('sw-v2.js', 'sw.js'); // a deploy, while this tab stays open await s.goto(url); await s.page.evaluate(() => new Promise((r) => setTimeout(r, 1500))); show('D. after a deploy, tab still open', await states(s.page)); } finally { await s.close(); }node sw-states.mjs http://localhost:8613/Chrome Chrome/152.0.7977.76 A. page loaded, install still running scope http://localhost:8613/ installing installing (app-v1) waiting null active null controller null B. navigator.serviceWorker.ready resolved scope http://localhost:8613/ installing null waiting null active activated (app-v1) controller null C. after one reload scope http://localhost:8613/ installing null waiting null active activated (app-v1) controller activated (app-v1) D. after a deploy, tab still open scope http://localhost:8613/ installing null waiting installed (app-v2) active activated (app-v1) controller activated (app-v1)Four readings, four answers to one question. At A a registration exists and
activeis null: the worker is running its install, and nothing it caches is available yet. At B the worker isactivatedandcontrolleris still null, because a document loaded without a controller keeps none for its whole life. At C, one reload later, that worker controls the page. At D a second version sits inwaitingwhile version 1 keeps serving, the state How to check if service worker is updated is about. - Step 2.
Confirm which of those states puts the worker in the request path.
PerformanceResourceTiming.workerStartis above zero for a response that went through a service worker, anddeliveryTypenames where the body came from.// sw-serving.mjs import { copyFileSync } from 'node:fs'; import { open } from './session.mjs'; const url = process.argv[2].replace(/\/$/, ''); const timing = (p) => p.evaluate(() => { const e = performance.getEntriesByType('resource').find((r) => r.name.endsWith('/app.css')); const ctl = navigator.serviceWorker.controller ? 'set' : 'null'; return e ? `controller ${ctl} workerStart ${e.workerStart.toFixed(1)} transferSize ${e.transferSize} deliveryType "${e.deliveryType}"` : `controller ${ctl} no entry for /app.css`; }); copyFileSync('sw-v1.js', 'sw.js'); const s = await open(); try { await s.goto(url + '/'); await s.page.evaluate(() => navigator.serviceWorker.ready); console.log('first visit, worker activated ', await timing(s.page)); await s.goto(url + '/'); console.log('after one reload ', await timing(s.page)); } finally { await s.close(); }node sw-serving.mjs http://localhost:8613/first visit, worker activated controller null workerStart 0.0 transferSize 340 deliveryType "" after one reload controller set workerStart 8.6 transferSize 0 deliveryType "cache-storage"The same file, the same activated worker, two results. On the first visit
/app.csscame off the network as 340 bytes and never touched the worker. After the reload it came from the worker,transferSize 0,deliveryType "cache-storage".activesays a worker exists.controllerandworkerStartsay it is serving. - Step 3.
Read the state a failed install leaves behind.
sw-broken.jsprecaches one path that returns 404, which is what a stale build manifest produces.// sw-failed.mjs import { copyFileSync } from 'node:fs'; import { open } from './session.mjs'; import { states, show } from './probe.mjs'; const url = process.argv[2]; copyFileSync('sw-broken.js', 'sw.js'); // one precache URL returns 404 const s = await open(); try { await s.goto(url); await s.page.evaluate(() => new Promise((r) => setTimeout(r, 4000))); show('first visit, install failed', await states(s.page)); const caches_ = await s.page.evaluate(async () => { const out = []; for (const n of await caches.keys()) out.push(`${n} (${(await (await caches.open(n)).keys()).length} entries)`); return out.join(' ') || '(none)'; }); console.log(' caches ', caches_); for (const m of s.console) console.log(' console ', m); } finally { await s.close(); }node sw-failed.mjs http://localhost:8613/first visit, install failed registration null caches app-v1 (0 entries) console error: Failed to load resource: the server responded with a status of 404 (Not Found)getRegistration()returned undefined: a first install that fails leaves no registration. The cache namedapp-v1survives with zero entries, becausecaches.open()creates it beforeaddAll()fetches anything. A check that looks for the cache name passes here. A check that reads the registration fails, and the console carries the 404 behind it.
How to read the result
| What you see | What it means | What to do |
| --- | --- | --- |
| registration null | Nothing is registered for this URL | Check the scope, and that the origin is https or localhost. A failed first install also lands here. |
| installing set, active null | The first install is running | Reread after it finishes. A cache with 0 entries and a 404 in the console means it failed. |
| active activated, controller null | The worker runs, this document is not its client | Reload once. Call clients.claim() in activate if the first load has to be controlled. |
| waiting installed beside active activated | A new version is installed and parked | Read How to check if service worker is updated. |
| workerStart above 0, deliveryType "cache-storage" | The response came out of the worker's cache | Nothing. This is the only reading that proves the worker is serving. |
Common mistakes
What to check next
- How to check if service worker is updated: why a deploy sits in
waiting, and which tab has to close. - How to check what a service worker has cached: every cache name, every entry, and the bytes behind them.
- How to test offline mode in chrome devtools: the test that means something only once the worker controls the page.
- How to check web manifest: the other half of an installable app, which fails independently.
- How to check localstorage in chrome: storage a worker never touches, read from the same console.
FAQ
How do I check if a service worker is active?
Read registration.active. It holds the worker that answers requests for the clients it controls, and active.state is activating or activated. Active is not the same as serving: on the first visit in step 1, active was activated while navigator.serviceWorker.controller was still null.
How do I check a service worker in Chrome?
Open DevTools, Application panel, Service workers pane. It lists the script URL, the status of each worker and the scope, with controls for update on reload and bypass for network. The console reading in step 1 returns the same three slots, and an automated check can assert on it.
How do I understand service worker scope?
Scope is the URL prefix a registration controls, and it defaults to the directory of the worker script. In one capture, registering /sw.js with { scope: '/app/' } gave scope http://localhost:8613/app/. On /about.html, getRegistration() returned undefined, while /app/ reported a controller.
Does a service worker need HTTPS?
Yes, apart from localhost. Chrome treats http://localhost and http://127.0.0.1 as secure contexts, so every capture here ran over plain HTTP on port 8613 with no certificate. A staging host on plain HTTP registers nothing.
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
intermediate7 minpublished updated Maks Verny