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

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

Steps

  1. 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 active is null: the worker is running its install, and nothing it caches is available yet. At B the worker is activated and controller is 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 in waiting while version 1 keeps serving, the state How to check if service worker is updated is about.

  2. Step 2.

    Confirm which of those states puts the worker in the request path. PerformanceResourceTiming.workerStart is above zero for a response that went through a service worker, and deliveryType names 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.css came off the network as 340 bytes and never touched the worker. After the reload it came from the worker, transferSize 0, deliveryType "cache-storage". active says a worker exists. controller and workerStart say it is serving.

  3. Step 3.

    Read the state a failed install leaves behind. sw-broken.js precaches 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 named app-v1 survives with zero entries, because caches.open() creates it before addAll() 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

Sign: A cache with the application's name exists, so the worker installed.Cause: The failed run in step 3 left app-v1 (0 entries) and no registration whatsoever. caches.open() creates the cache before addAll() fetches one byte, so the name outlives an install that never completed.
Sign: The first visit is treated as covered because the worker reached activated.Cause: Step 2 measured the gap. On the first visit /app.css arrived with transferSize 340 and workerStart 0. After one reload the same file arrived with transferSize 0 from cache-storage. An offline test run on the first visit tests the network.
Sign: navigator.serviceWorker.ready is used as the test for a registration.Cause: It resolves only for a registration whose scope covers the current page, and it never rejects. Awaiting it on a page outside the scope hung this harness until the 120 s timeout, with no error and no output. getRegistration() answers undefined instead.

What to check next

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.

intermediate7 minpublished updated Maks Verny