Service Worker Lifecycle Traps

TL;DR / Service workers progress through install, wait, and activate phases with strict rules about when they can intercept fetches -- and the waiting phase catches most developers off guard.

How It Works

 ┌──────────┐          ┌────────────┐          ┌──────────┐
 │ Register │─────────→│ Installing │─────────→│ Waiting  │
 └──────────┘          └────────────┘          └──────────┘
                       caches assets                 │
                                                     │
 old SW controls                                     │
 all open tabs                                       │
                                                     └┐
                                                      │
 all tabs close OR skipWaiting()                      │
                                                      │
                                                      ↓
                                               ┌────────────┐
 clean old caches                              │ Activating │
                                               └────────────┘
                                                      │
                                                      │
                                                      │
                                                      │
                                                      ↓
                                               ┌────────────┐
 handles fetch, push, sync                     │   Active   │
                                               └────────────┘

Edit diagram

A service worker is a script that runs in a separate thread from your page, acting as a programmable network proxy. It intercepts fetch events, manages caches, and enables offline functionality. But its lifecycle is fundamentally different from any other web API, designed to ensure that at most one version of your service worker controls a given page at a time.

Registration and Install

When you call navigator.serviceWorker.register('/sw.js'), the browser downloads the script and compares it byte-for-byte to any currently installed version. If it differs (even by a single byte, including comments), the browser starts installing the new version. The install event fires in the new service worker's context.

The install phase is where you pre-cache static assets. You call event.waitUntil(cachePromise) to tell the browser not to consider installation complete until your caching promises resolve. If any promise rejects, the entire installation fails and the service worker is discarded. The old version (if any) continues operating.

The Waiting Phase

After successful installation, the new service worker enters the "waiting" state. It does not begin handling fetch events. It waits until all tabs controlled by the old service worker are closed. This is the behavior that catches developers: you deploy a new service worker, refresh the page, and the old version is still serving requests.

The reason is safety. The old service worker and the new one may have different caching strategies, different route handling, or different data expectations. If the new worker took over mid-session, some tabs would be running with assets cached by the old worker while fetches go through the new one. The spec prevents this by default.

skipWaiting and clients.claim

self.skipWaiting() called during the install event forces the new service worker to skip the waiting phase and move directly to activating. self.clients.claim() called during the activate event makes the new worker immediately take control of all open tabs in its scope, including tabs that were loaded before the worker was registered.

Together, these bypass the safety mechanism entirely. The new worker takes over immediately on all tabs. This is convenient for development and acceptable when your cache strategy is backward-compatible, but dangerous when it is not. If the new worker caches assets with a different naming scheme, existing tabs may break because their in-memory DOM references point to assets that the new worker's fetch handler routes differently.

The Activate Phase

Once the service worker exits waiting (either because all old-version tabs closed or skipWaiting was called), the activate event fires. This is the place to clean up stale caches. You enumerate all cache names, compare them to your current expected names, and delete the rest. event.waitUntil() again controls when activation is considered complete.

After activation, the worker is "activated" and will receive fetch, push, sync, and other functional events. However, a subtlety: the page that triggered the new worker's registration will not have its fetches intercepted by the new worker unless clients.claim() is called. Without it, only navigations that occur after activation -- meaning new page loads or refreshes -- route through the new worker.

Update Checks

The browser checks for service worker updates on navigation, when a push or sync event fires, and when .register() is called again. The HTTP cache is respected unless the service worker script is served with Cache-Control: max-age=0 (or the browser uses the 24-hour maximum cache lifetime the spec mandates for service worker scripts). After 24 hours, the browser always goes to the network for the script, regardless of cache headers.

If the new script is byte-identical, no installation happens. If it differs, the install/wait/activate cycle begins again. This means you must change the service worker file itself to trigger an update -- changing only the cached assets without changing the worker script does not trigger a new installation.

Scope

The scope option in register() determines which navigations trigger the worker's fetch handler. A worker at /app/sw.js with default scope can only control pages under /app/. You cannot register a worker with a scope broader than its own directory unless the server sends the Service-Worker-Allowed header.

Gotchas

  • New service workers wait by default -- refreshing the page is not enough. All tabs controlled by the old worker must close. Use skipWaiting() only when your caching strategy is backward-compatible with already-loaded pages.
  • clients.claim() without skipWaiting() does nothing for the first load -- a fresh page load with no prior service worker will not be controlled until the next navigation unless clients.claim() is called during activation.
  • Cache cleanup belongs in the activate event, not install -- deleting caches during install can break the still-active old worker that relies on those caches.
  • event.waitUntil() must be called synchronously in the event handler. If you await before calling waitUntil, the event may finish before your promise is registered, causing unpredictable behavior.
  • DevTools' "Update on reload" checkbox bypasses the entire lifecycle -- it forces install-then-activate on every reload, hiding waiting-phase bugs that only appear in production.