Rethinking the service worker layer
Service workers let a site load instantly, work offline, and be installable as an app. But they behave differently from anything else in web development. Thinking of a service worker as a browser extension helps clarify what it actually is: a middle layer your site installs in the user's browser, capable of intercepting and handling every request the site makes.
That middle layer has its own lifecycle, independent of the browser tab. A page refresh won't update a service worker any more than it would update server code. Each layer follows its own rules for when and how it changes.
Powerful, but deliberately constrained
With a service worker, a site can work offline, gain large performance improvements through caching, use push notifications, and be installable as a PWA. But the capabilities are limited by design: a service worker can't do anything synchronous or run on the same thread as the site. It has no access to localStorage, the DOM, or the window object.
Pages can still communicate with their service worker through direct postMessage calls, one-to-one Message Channels, and one-to-many Broadcast Channels.
Lives long, sleeps often
An active service worker keeps running after the user closes the tab, waiting to intercept the first request the next time the user returns. That persistence is what makes offline support possible. But despite this longevity, the browser can stop a service worker at almost any time to avoid wasting resources. Being stopped is not termination: the service worker stays installed and activated, and the browser wakes it when needed.
Because of that constant stop/start possibility, a service worker needs a way to signal that it's doing something important. event.waitUntil() extends the current lifecycle phase, keeping the worker from being stopped and from advancing to the next phase until the work completes. This gives time to set up caches or fetch network resources during installation.
Global state is a trap here. Every time the browser stops and restarts a service worker, its script is evaluated again and its global scope is reset. A variable like hasHandledARequest that was set to true will be false on the next wake-up. Similarly, instances of objects like Message Channels are recreated from scratch on each start, which can introduce subtle bugs. Stored state in a service worker can't be trusted.
One controller, but two installed
A page can be controlled by only one service worker at a time, but two can be installed simultaneously. Service workers are immutable: when you change your code and refresh the page, you're not editing the existing worker—you're creating a brand new one. The new worker installs but doesn't activate. It waits for the current worker to terminate, which typically happens when the user leaves the site.
During installation, the new worker has full access to everything the current worker accesses. Without care, a waiting worker can break the active one. It could delete a cache the active worker is using, or edit cached contents and cause the active worker to respond with assets the page doesn't expect.
The skipWaiting() method lets a worker take control as soon as it finishes installing. That's risky: the new worker may rely on updated resources the current page isn't prepared for. It's generally best reserved for intentionally replacing a buggy worker.
Version caches, clean up after yourself
Keeping workers from clobbering each other comes down to using separate caches. The straightforward way is to version cache names. When a new worker deploys, bump the version so it operates on a cache entirely separate from the previous worker's.
Cleanup matters too. Once a new worker reaches the activated state, the previous worker is redundant and its caches should be deleted—both to respect cache storage limits and to prevent bugs. caches.match() is a convenient shortcut that retrieves an item from any matching cache, but it iterates caches in creation order. If app.js exists in both assets-1 and assets-2, and the page expects the newer version in assets-2, leaving the old cache behind means caches.match('app.js') returns the stale file from assets-1. Deleting caches the new worker doesn't need prevents that failure mode.
The mindset shift
Service workers demand a different mental model than typical client-server development. Accepting their independent lifecycle, their sleep/wake behavior, and the coexistence of multiple installed versions makes them far easier to work with. Once the mindset clicks, they become a reliable tool for building resilient, fast, installable web experiences.



