The essentials

Quick reference

One focused task per row. Jump to the related section for complete, working examples.

UseSyntaxExamples
Register within a scopenavigator.serviceWorker.register('/app/sw.js', { scope: '/app/' })View examples
Wait for an active workerconst registration = await navigator.serviceWorker.readyView examples
Check current controlconst controlled = navigator.serviceWorker.controller !== nullView examples
Extend installationevent.waitUntil(precachePromise)View examples
Open a named cacheconst cache = await caches.open('app-shell-v4')View examples
Precache required URLsawait cache.addAll(['/app/', '/app/offline.html'])View examples
Extend activationevent.waitUntil(deleteOldCaches())View examples
Claim matching clientsawait self.clients.claim()View examples
Activate a waiting updateregistration.waiting.postMessage({ type: 'ACTIVATE_UPDATE' })View examples
Provide a fetch responseevent.respondWith(responsePromise)View examples
Find a cached responseconst cached = await caches.match(event.request)View examples
Store a response cloneawait cache.put(request, response.clone())View examples
Check for an updateawait registration.update()View examples
Observe new controlnavigator.serviceWorker.addEventListener('controllerchange', reloadOnce)View examples

A service worker is an event-driven network proxy associated with an origin and scope, not a permanently running background process. It can intercept controlled clients' requests and serve explicitly stored Request/Response pairs from the Cache API. Reliable offline behavior requires a secure deployment, narrow routing rules, versioned caches, bounded storage, intentional fallbacks, and an update protocol that prevents old pages from talking to incompatible new code.

Step by step

Detailed examples

01

Register securely and keep scope intentional

Service workers require a trustworthy context, normally HTTPS, with localhost treated specially for development. The default maximum scope is the worker script's directory; placing /app/sw.js beside the application naturally permits /app/. A broader scope requires a Service-Worker-Allowed response header and should be a conscious server decision. Registration resolving means the registration exists, not that this first page is controlled; ready waits for an active registration, while controller reveals actual control.

Register and distinguish activation from control
if ('serviceWorker' in navigator) {
  try {
    const registration = await navigator.serviceWorker.register('/app/sw.js', {
      scope: '/app/'
    });
    console.log('Registered scope:', registration.scope);

    await navigator.serviceWorker.ready;
    console.log('Active registration is ready');
    console.log('This page is controlled:', navigator.serviceWorker.controller !== null);
  } catch (error) {
    console.error('Service worker registration failed', error);
  }
}
Back to quick reference ↑
02

Make installation an atomic application-shell gate

Register lifecycle listeners at the worker's top level because the browser may stop and restart its global scope between events. install is the place to cache a small, versioned set of resources required for the offline shell. Pass the complete promise to waitUntil: if a required fetch or cache write rejects, the new worker does not install and the previous version remains available. Keep optional or cross-origin resources out of the critical precache transaction.

Precache a minimal versioned shell
// /app/sw.js
const VERSION = 'v4';
const PRECACHE = `app-precache-${VERSION}`;
const REQUIRED = [
  '/app/',
  '/app/styles.css',
  '/app/app.js',
  '/app/offline.html'
];

self.addEventListener('install', event => {
  event.waitUntil(
    caches.open(PRECACHE).then(cache => cache.addAll(REQUIRED))
  );
});
Back to quick reference ↑
03

Activate compatible code and remove only owned caches

A successfully installed update normally waits until pages using the old worker close. During activate, delete obsolete caches owned by this application and preserve unrelated names from the same origin. clients.claim can control eligible existing pages immediately, and skipWaiting can replace an active worker immediately; both are useful only when the new worker remains protocol-compatible with already loaded pages. Prefer prompting the user before activating a breaking update.

Clean owned caches and accept an explicit update
const OWNED_PREFIX = 'app-';
const CURRENT = new Set([PRECACHE, `app-runtime-${VERSION}`]);

self.addEventListener('activate', event => {
  event.waitUntil((async () => {
    const names = await caches.keys();
    await Promise.all(names
      .filter(name => name.startsWith(OWNED_PREFIX) && !CURRENT.has(name))
      .map(name => caches.delete(name)));
    await self.clients.claim();
  })());
});

self.addEventListener('message', event => {
  if (event.data?.type === 'ACTIVATE_UPDATE') {
    event.waitUntil(self.skipWaiting());
  }
});
Back to quick reference ↑
04

Route requests before choosing a caching strategy

Call respondWith during fetch-event dispatch, and ignore requests your worker does not explicitly own. A network-first navigation can return fresh HTML and fall back to a cached page when offline. Versioned same-origin static assets often suit stale-while-revalidate: return a cached response immediately while a successful network response refreshes the runtime cache. fetch resolves for HTTP errors, so check response.ok before caching; clone a response because bodies are streams that can be consumed only once.

Use network-first navigation and stale-while-revalidate assets
const RUNTIME = `app-runtime-${VERSION}`;

async function storeSuccessful(request, response) {
  if (!response.ok) return;
  try {
    const cache = await caches.open(RUNTIME);
    await cache.put(request, response.clone());
  } catch (error) {
    console.warn('Runtime cache write failed', error);
  }
}

self.addEventListener('fetch', event => {
  const request = event.request;
  const url = new URL(request.url);

  if (request.mode === 'navigate') {
    event.respondWith((async () => {
      try {
        const response = await fetch(request);
        await storeSuccessful(request, response);
        return response;
      } catch {
        return (await caches.match(request))
          ?? (await caches.match('/app/offline.html'))
          ?? new Response('Offline', { status: 503, headers: { 'Content-Type': 'text/plain' } });
      }
    })());
    return;
  }

  const staticAsset = request.method === 'GET'
    && url.origin === self.location.origin
    && ['style', 'script', 'image'].includes(request.destination);
  if (!staticAsset) return;

  const networkUpdate = fetch(request).then(async response => {
    await storeSuccessful(request, response);
    return response;
  });

  event.waitUntil(networkUpdate.then(() => undefined, () => undefined));
  event.respondWith(caches.match(request).then(cached => cached ?? networkUpdate));
});
Back to quick reference ↑
05

Ship observable updates and bound cached data

Cache entries do not become fresh merely because the network resource changed, and the Cache API does not implement an application expiration policy for you. Version immutable shell assets, cap or expire runtime entries, and avoid caching personalized or sensitive responses unless the policy is deliberate. Keep the service worker script at a stable URL with revalidation-friendly HTTP headers. update asks for a check; when an installed worker is waiting, let the page notify the user, message that exact worker, then reload once after controllerchange.

Offer a waiting update and reload after takeover
const registration = await navigator.serviceWorker.getRegistration('/app/');
let reloading = false;

navigator.serviceWorker.addEventListener('controllerchange', () => {
  if (reloading) return;
  reloading = true;
  location.reload();
});

if (registration) {
  registration.addEventListener('updatefound', () => {
    const candidate = registration.installing;
    candidate?.addEventListener('statechange', () => {
      if (candidate.state === 'installed' && navigator.serviceWorker.controller) {
        document.querySelector('#update-notice').hidden = false;
      }
    });
  });

  document.querySelector('#apply-update').addEventListener('click', () => {
    registration.waiting?.postMessage({ type: 'ACTIVATE_UPDATE' });
  });

  await registration.update();
}
Back to quick reference ↑

Sources and further reading

References

Authoritative documentation used to verify and expand this cheat sheet.

  1. World Wide Web ConsortiumService Workersw3c.github.io
  2. World Wide Web ConsortiumService worker lifecyclew3c.github.io
  3. World Wide Web ConsortiumCache interfacew3c.github.io
  4. WHATWGFetch Standard: HTTP fetchfetch.spec.whatwg.org
  5. World Wide Web ConsortiumSecure Contextsw3c.github.io

Help us improve

Found a typo or missing example?

Tell us what would make this cheat sheet clearer, more complete, or more useful.

Share feedback