Skip to content

Service Worker - code that answers instead of the server

Profile photo of Adrian Zawadzki

Adrian Zawadzki

12 min read

Turn off the internet, refresh the page - and it still works. Behind that is the Service Worker: a separate script that steps between the page and the network and intercepts every request for a file before it reaches the server. It decides whether to answer from the network, from its own cache (Cache API), or on its own terms - and from that one decision come all the caching strategies, offline mode, and the classic 'after a deploy the user sees the old version' trap.

You turn off the internet, refresh the page - and it still loads. Not by accident, not because something lingered in memory - someone planned it. Between the page and the network there sits a separate script that intercepted the request for a file and answered it itself, with a copy it had stored earlier.

In the previous post on HTTP caching the cache lived in the browser, and you influenced it only indirectly - through headers in the server’s response. The server set the rules, the browser made the decisions, and all you could do was hint. The Service Worker flips that: it hands you code that intercepts every request itself and decides what to answer.

#A separate script that stands in front of the network

A Service Worker is a JS file that the browser runs separately from the page - in its own thread, in the background, with no access to the DOM. You can’t touch document or window from it - its global object is self (which is why in the code below you’ll see self.addEventListener). In return it gets something an ordinary script doesn’t: it stands as an intermediary between the page and the network. Every request the page sends - an image, CSS, an API call - fires its fetch event before anything goes out to the network. And mind the name: fetch is the name of an event, not of the fetch() method. The event catches requests at the network level, so it doesn’t matter what you fired them with - fetch(), XMLHttpRequest, or a library like axios (which under the hood uses one of those anyway). Every request from a controlled page passes through the same event.

And since it passes through, the Service Worker decides what to do with it. It has three options: answer with a copy from the cache, build the response itself (say, a “you’re offline” page or generated JSON, without touching the network), or pass the request on to the network as if it weren’t there at all. With one caveat - it has to already control that tab (the open page in the browser). “Control” is a specific relationship: only once the browser assigns a given tab to an installed Service Worker do its requests start passing through it. A tab it doesn’t control it doesn’t even touch - that tab’s requests go straight to the network, as if the worker weren’t there. And there’s a catch: on the first visit the worker is only just installing, so the very tab that registered it doesn’t come under its control until the next visit (more on that in the lifecycle).

That’s what sets it apart from an ordinary Web Worker. A Web Worker is also a separate thread, but it serves one purpose: to compute something heavy off the main thread so the interface doesn’t freeze. A Service Worker doesn’t do the computing - its job is intercepting the network and the cache. One takes load off the CPU, the other steps between you and the server.

You tell the page about it once, at startup:

if ("serviceWorker" in navigator) {
  navigator.serviceWorker.register("/sw.js");
}

A Service Worker controls only the paths within its scope - by default the directory it was registered from. sw.js in the root directory controls the whole site; the same file in /app/ would control only /app/*. That’s why the script usually lands in the root.

#Three acts: install, activate, fetch

A Service Worker lives in three events. The first is install - it fires once, when the browser sees this script for the first time. This is the moment for precaching: you drop into the cache the files the app will definitely need.

const CACHE = "assets-v1";

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

caches.open opens a named store (I keep the name in the CACHE constant, because it’ll come in handy in a moment), addAll fetches a list of files and saves them there. event.waitUntil tells the browser: don’t consider the installation finished until this completes.

This is the Cache API - a store of request/response pairs that you manage by hand. And take note, because it’s not the same as the HTTP cache from the previous post: there the browser stored and invalidated entries on its own, according to headers. Here you open the store, you put things in, you throw them out.

The second event, activate, is the moment for cleanup. When you deploy a new version, you bump the CACHE constant (say, to assets-v2), and activate deletes every store with a different name - that is, everything left over from the old version:

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

caches.keys() returns the names of all the stores, and you delete the ones that aren’t the current CACHE. Without this, old files stay in the cache forever.

The third event, fetch, is the heart of it. It fires on every request from the page:

self.addEventListener("fetch", (event) => {
  event.respondWith(
    caches
      .match(event.request)
      .then((cached) => cached ?? fetch(event.request)),
  );
});

event.respondWith tells the browser: I’m taking this response on myself - don’t fetch it from the network yourself, use what I give you. And what we give is a simple question: do I have this in the cache? caches.match looks for a stored copy of this request. There’s a copy - we hand it back right away. There isn’t - only then do we go to the network for the file (fetch). And that’s the whole strategy already: cache first, the network only when needed.

#Strategies: how to answer a request

Since you’re the one writing respondWith, you choose where the response comes from. Three patterns come up most often.

Cache-first - the cache first, and go to the network only when the cache has nothing. That’s the pattern from the example above. Great for files that don’t change.

Network-first - the network first, with the cache as a plan B. For data that should be fresh, but where, when the network is gone, showing the last known version beats an error. The typical choice anywhere you care about freshness but want offline to be at least a fallback.

Stale-while-revalidate - hand back the copy from the cache right away, and in the background pull a fresh one and swap it in for next time:

self.addEventListener("fetch", (event) => {
  event.respondWith(
    caches.open("pages").then(async (cache) => {
      const cached = await cache.match(event.request);
      const fresh = fetch(event.request).then((response) => {
        cache.put(event.request, response.clone());
        return response;
      });
      return cached ?? fresh;
    }),
  );
});

One detail in that code is worth catching: response.clone(). A network response can be read only once, and here you need it twice - one copy you hand to the page, the other you put in the cache. That’s why you clone it: without it, the second read would blow up.

It’s the same stale-while-revalidate as in the header from the previous post on HTTP caching - except there a CDN or the browser did it according to a directive, and here you write it yourself. The user never waits, at the cost of sometimes seeing content from one visit ago.

The choice of strategy isn’t arbitrary - it depends on what you’re serving. Files with a hash in the name (the immutable ones from the previous post) fit cache-first, because under a given name they won’t change anyway. API data that has to be current calls for network-first. For content where speed matters and a brief staleness does no harm, stale-while-revalidate is what’s left.

The same three strategies, side by side. Set the strategy, the network state, and whether the file is already in the cache - and see what the user gets, and from where:

Flip the strategy, the network state and the cache, and watch what the user gets and from where.

Strategy
Network
Cache
What the user gets
source
from cache
content
may be stale
speed
instant

Cache-first has a copy, so it serves it immediately and never touches the network - same result online and offline.

#Cache invalidation: the most common trap

Back comes the question that runs through this whole series: since you manage the cache, you’re the one who invalidates it. And with Service Workers it’s invalidation, precisely, that’s the most common source of pain - because of how their update works.

When you change sw.js and a user visits the site, the browser downloads the new script but doesn’t activate it right away. The new version waits (the waiting state) until all tabs with the old Service Worker are closed. As long as the user keeps even one open, the old script runs - and serves old files from the old store.

Two tools break through this. self.skipWaiting() (in install) handles the wait for activation: it tells the browser not to hold the new version in the waiting state but to activate it immediately. But activation alone isn’t enough - the fresh worker still doesn’t control tabs that are already open until you reload them. That’s what self.clients.claim() (in activate) is for: it tells the worker to take over already-open tabs on the spot, without waiting for a reload. Together they make the new version take over the page on the next visit, rather than only after all the old tabs are closed.

Except it’s a double-edged sword: if the new script starts serving new assets to a page that loaded with the old ones, you can get an inconsistency. That’s why versioning the stores (assets-v1assets-v2) and cleaning up in activate stops being an option and becomes an obligation.

And one more thing that ties this post to the previous one: a Service Worker doesn’t replace the HTTP cache, it stands in front of it. A request goes in order: page → Service Worker → (if it calls the network) HTTP cache → server. The sw.js file itself is also subject to the HTTP cache, so it’s usually served with no-cache, so the browser always checks whether there’s a new version of the script - otherwise you loop the problem one level up.

#Not just cache

Service Workers are most often reached for to do caching and offline mode, but the same intermediary powers push notifications and background sync too - separate APIs built on the same foundation.

In practice you rarely write these handlers by hand. Libraries like Workbox give you ready-made strategies (CacheFirst, NetworkFirst, StaleWhileRevalidate) and handle versioning for you. But underneath it’s exactly the same model - it’s worth understanding what they generate before you lean on them.

#What to remember

  1. A Service Worker is a programmable intermediary between the page and the network - a separate thread, no DOM, intercepts every fetch and decides what to answer on its own.
  2. The Cache API is your store - you write to it, you invalidate it. Version the store names and clean up the old ones in activate.
  3. You match the strategy to the content - cache-first for immutable assets, network-first for fresh data, stale-while-revalidate for the compromise.
  4. The update is lazy - the new worker waits until you close the old tabs. skipWaiting and clients.claim speed it up, but they demand discipline in versioning.

Next time an app stubbornly shows the old version after a deploy despite a cleared browser cache, don’t guess - open Application → Service Workers in DevTools and check whether an old worker is hanging there in the waiting state, handing out files from its own store. Clearing the browser cache won’t budge it - it’s a separate layer that you wrote yourself, and it serves exactly what you told it to store.

Comments

Loading comments…

Leave a comment