Skip to content

Web Development · Frontend

Service Workers Explained: Offline Caching Step by Step

A service worker is a script that sits between your page and the network. Register one, cache assets, serve offline, and ship updates without stale-cache bugs.

Abhishek Gupta

Abhishek Gupta

7 min read

A small clay booth on a road between a cottage and a tower, with colored blocks stacked on a shelf inside

Sponsored

Share

A service worker is a script your browser runs in the background, separate from any page, that can intercept network requests and answer them from a cache. Service workers explained simply: they’re how a web app loads with no connection. It’s also how a web app gets stuck on a broken old version for weeks, and most of the work in using one well is avoiding that second outcome.

This tutorial builds a small, correct offline setup from scratch, then goes through the mistakes that cause stale-cache bugs. The code follows the patterns in MDN’s service worker guide.

What a service worker actually is

A service worker is a JavaScript file the browser runs on its own thread, apart from your pages. It acts as a programmable proxy for every page in its scope: it sees each network request and can answer from the Cache API, from the network, or with a response it builds itself.

Three properties explain nearly everything about its behavior.

  1. It’s a separate thread. It has no access to the DOM. It talks to pages through events and messages.
  2. It’s a proxy for its scope. Once registered, it sees the fetch event for requests from every page it controls, and can answer them itself.
  3. It lives on its own schedule. The browser can start it for an event and stop it when idle, so it can’t hold state in variables. Anything persistent goes in the Cache API or IndexedDB.

And it only runs over HTTPS. MDN says so directly: service workers are restricted to HTTPS for security reasons, with localhost as the exception for local development. This makes sense. A worker that can rewrite every response for your origin is exactly what an attacker would want.

Step 1: Register it

In your page’s JavaScript:

if ("serviceWorker" in navigator) {
  window.addEventListener("load", async () => {
    try {
      const reg = await navigator.serviceWorker.register("/sw.js", { scope: "/" });
      console.log("service worker registered", reg.scope);
    } catch (err) {
      console.error("registration failed", err);
    }
  });
}

Put sw.js at the site root. A worker’s default scope is the directory it lives in, and it can’t claim a broader scope than its own location unless the server sends a Service-Worker-Allowed header. A worker at /assets/js/sw.js can’t control /. This catches people constantly.

Step 2: Precache in the install event

const CACHE = "app-v1";
const PRECACHE = ["/", "/offline.html", "/styles.css", "/app.js"];

self.addEventListener("install", (event) => {
  event.waitUntil(
    caches.open(CACHE).then((cache) => cache.addAll(PRECACHE))
  );
});

event.waitUntil tells the browser the install isn’t done until the promise settles. If any URL in addAll fails, the whole install fails and the worker is discarded, which is what you want: a half-cached app is worse than none.

Keep this list short. Precache the app shell and an offline fallback page, and let everything else be cached as it’s used.

Step 3: Clean up old caches on activate

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

This is the step people skip, and then wonder why storage grows and old files linger. Bump CACHE to app-v2 when you ship, and this handler deletes app-v1.

Step 4: Answer requests in fetch

Here’s a cache-first strategy with a network fallback and an offline page:

self.addEventListener("fetch", (event) => {
  if (event.request.method !== "GET") return;

  event.respondWith(
    (async () => {
      const cached = await caches.match(event.request);
      if (cached) return cached;

      try {
        const response = await fetch(event.request);
        const copy = response.clone();
        event.waitUntil(
          caches.open(CACHE).then((cache) => cache.put(event.request, copy))
        );
        return response;
      } catch {
        return caches.match("/offline.html");
      }
    })()
  );
});

The .clone() isn’t optional. A response body can be read once, so to both cache it and hand it to the browser you need two copies. Forgetting it produces a very confusing “body already used” error.

Choosing a strategy per request type

Cache-first everywhere is the classic mistake. Match the strategy to what you’re serving:

ContentStrategyWhy
Hashed static files (app.3f9a1c.js)Cache-firstThe URL changes when the content does, so a cached copy is never stale
HTML pagesNetwork-first, cache fallbackYou want fresh content when online and something when not
API dataNetwork-first or stale-while-revalidateStale data is sometimes fine, but you decide that deliberately
Images and fontsCache-firstRarely change, expensive to refetch

If you’re already setting Cache-Control and ETags properly, the service worker is a second cache layered over the first, and the two interact. HTTP caching with Cache-Control, ETags and CDNs is worth reading before you add one, because a long max-age on sw.js itself is a good way to make updates take days.

The update lifecycle, and why it confuses everyone

When you deploy a changed sw.js, the browser sees it on a later page load or navigation, installs it in the background, and then, per MDN, the new version enters a waiting state until all pages controlled by the old version close. The old worker keeps serving meanwhile.

That’s a feature: it stops a page from running half on old code and half on new. It’s also why “I deployed but I still see the old site” is the most common service worker bug report. Closing the tab and reopening it usually isn’t enough if another tab is open.

Two escape hatches exist:

  • self.skipWaiting() in the install handler activates the new worker immediately.
  • self.clients.claim() in the activate handler lets it take over pages that are already open. MDN notes that a document starts life with or without a service worker and keeps that for its lifetime, so without claim() existing tabs stay uncontrolled until reloaded.

I’d use skipWaiting only when the new worker is safe to run against pages loaded from the old version. If you renamed an asset or changed an API contract, that assumption breaks, and it’s better to show a “refresh to update” banner and let the user choose.

Debugging checklist

  • Open DevTools, Application, Service Workers. Tick “Update on reload” while developing.
  • “Bypass for network” lets you test the live site without the worker.
  • Test offline with the Network tab’s offline toggle, then reload.
  • Never serve sw.js with a long cache lifetime. Use no-cache or a short max-age.
  • Keep a kill-switch plan: a worker that unregisters itself (registration.unregister()) and clears caches, ready to deploy if a bad one ships.

A service worker is also the foundation for web push and for installable apps. If that’s where you’re heading, web push notifications without a vendor builds on this exact setup, and how PWAs compare with native and cross-platform apps covers whether it’s the right bet.

The takeaway

A service worker is a small amount of code with a long memory. Register it at the root, precache only the shell, version your caches and delete the old ones on activate, pick a caching strategy per content type, and plan for how updates reach users. Get those five right and offline support is a solid feature rather than a source of ghost bugs.

Frequently asked questions

What is a service worker?
A service worker is a script the browser runs in the background, apart from any page. It acts as a programmable proxy: it can intercept requests from the pages it controls and respond from the Cache API, from the network, or with a custom response. That is what makes offline support possible.
Do service workers require HTTPS?
Yes. Service workers only run on secure origins for security reasons, since a hijacked worker could intercept every request. The one exception is localhost, which browsers treat as secure so you can develop without certificates.
Why is my service worker not updating?
A new version installs in the background but stays in the waiting state while any page controlled by the old version is still open. Close all tabs, or call self.skipWaiting() in the install event, and the new worker activates. Also check that your cache names changed.
What is the difference between skipWaiting and clients.claim?
skipWaiting() lets a newly installed worker activate immediately instead of waiting for old pages to close. clients.claim() lets an activated worker take control of pages that are already open, which otherwise stay uncontrolled until reloaded.
Should I use a service worker for every site?
No. They add a caching layer you have to reason about and debug. They are worth it for apps that need offline use, instant repeat loads or push notifications. For a mostly static content site, good HTTP cache headers often give most of the benefit.

Sources

Sponsored

Sponsored

Discussion

Join the conversation.

Comments are powered by GitHub Discussions. Sign in with your GitHub account to leave a comment.

Sponsored