Service Workers: Making a Website Work Offline

October 9, 2026 · 3 min read

Normally, every request your page makes goes to the network, and with no network the page fails. A service worker changes that: it's a script the browser runs in the background, separately from your page, that can intercept every request and decide how to answer — from a cache, from the network, or both.

It's the technology behind offline web apps, "installable" Progressive Web Apps, and push notifications.

page
service worker
cache
network
cache storage
(empty)

A service worker is a script the browser runs separately from your page. Once installed, it sits between the page and the network and can answer requests itself — including when there is no network at all.

0 / 8

Registering

The page registers the worker once. It must be served over HTTPS (or from localhost), and its location sets its scope — a worker at /sw.js can control every page on the site.

// in your page
if ('serviceWorker' in navigator) {
  navigator.serviceWorker.register('/sw.js');
}

Install: precache the app shell

The install event runs once per new version of the worker. Use it to cache the files needed to show your interface:

// sw.js
const CACHE = 'shell-v3';

self.addEventListener('install', (event) => {
  event.waitUntil(
    caches.open(CACHE).then((cache) =>
      cache.addAll(['/', '/app.css', '/app.js', '/offline.html'])
    )
  );
});

waitUntil keeps the worker alive until the caching finishes; if it fails, the install fails and the old worker stays in charge.

Fetch: choose a strategy per request

Every request from a controlled page triggers the fetch event. The worker can answer with event.respondWith(...):

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

  if (url.pathname.startsWith('/api/')) {
    event.respondWith(networkFirst(event.request));
  } else {
    event.respondWith(cacheFirst(event.request));
  }
});

async function cacheFirst(request) {
  const cached = await caches.match(request);
  return cached ?? fetch(request);
}

async function networkFirst(request) {
  const cache = await caches.open('data');
  try {
    const response = await fetch(request);
    cache.put(request, response.clone());   // keep a copy for later
    return response;
  } catch {
    return (await cache.match(request)) ?? caches.match('/offline.html');
  }
}

The common strategies:

StrategyHow it answersGood for
Cache firstCache, falling back to networkVersioned static assets, fonts
Network firstNetwork, falling back to cacheAPI data, HTML that must be fresh
Stale-while-revalidateCache now, update cache in backgroundAvatars, non-critical content
Network onlyNever cachedPayments, anything that must be live

response.clone() matters: a response body can only be read once, so you clone it to both cache it and return it.

The lifecycle, and why updates confuse people

When you deploy a new sw.js, the browser notices it's different and installs it — but the new worker waits. The old one keeps controlling every open tab until all of them are closed, so a page is never served by two versions of your code at once.

That's why "I deployed but nothing changed" is the most common service worker complaint. Options:

  • Call self.skipWaiting() in install to activate immediately, and clients.claim() in activate to take over open pages — fine when old and new versions are compatible.
  • Or detect the waiting worker in the page and show "A new version is available — reload".

Use the activate event to delete caches from old versions:

self.addEventListener('activate', (event) => {
  event.waitUntil(
    caches.keys().then((keys) =>
      Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k)))
    )
  );
});

Things to know

  • A service worker can't touch the DOM. It talks to pages with postMessage, like a Web Worker.
  • It's easy to cache yourself into a corner. A bug that caches broken HTML can keep serving it. Always version caches and test updates.
  • It doesn't replace HTTP caching — it's a programmable layer on top. Get your headers right first.
  • In DevTools, the Application panel shows workers and caches, and has "Update on reload" and "Offline" toggles for testing.
  • Libraries like Workbox generate most of this code from a short configuration.

The takeaway

A service worker is a programmable proxy inside the browser: precache the app shell on install, pick a caching strategy per kind of request on fetch, and clean up old caches on activate. Mind the update lifecycle, and your site can load instantly on repeat visits and keep working with no connection at all.