awesome-everything RU
↑ Back to the climb

Browser & Frontend Runtime

Service worker lifecycle and cache strategies

Crux Register → install → waiting → activate → fetch: the lifecycle that enables offline apps, instant repeat loads, and the waiting-state trap that surprises everyone the first time.
Your altitude — climbing toward senior
ZeroJuniorMiddleSenior
You are at middle altitude — in the sky
◷ 15 min

You deploy a new service worker, reload the page, and still get the old behaviour. No error — the new worker installed fine. It is just waiting for you to close every tab.

Registration and scope

navigator.serviceWorker.register('sw.js') starts the lifecycle. The scope defaults to the directory of sw.js — a worker at /app/sw.js controls all pages under /app/. You can narrow it: register('sw.js', { scope: '/app/checkout/' }).

A service worker has no DOM and no persistent global state — the browser kills and restarts it freely to save memory. All durable state lives in the Cache API or IndexedDB. It runs on its own thread, independent of any page — it can receive push notifications and run background sync even when no tab is open.

The lifecycle

register → install → [waiting] → activate → idle ↔ running

install — fires when the browser parses the new worker for the first time. This is where you pre-cache the app shell:

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

event.waitUntil(promise) tells the browser: do not advance the lifecycle until this promise settles. Forget it and the browser may kill the worker mid-cache-population.

waiting — a newly installed worker does not take control of already-open pages by default. It waits until every tab using the old worker closes. This prevents a page from running old HTML with new cached assets.

activate — fires after waiting. This is where you delete old caches:

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

fetch — intercepts every network request in scope. event.respondWith(response) determines what the page receives.

Service worker lifecycle states
install → activate
Only after all old tabs close (without skipWaiting)
Idle worker killed by browser
Seconds after last event
State check (DevTools)
Application → Service Workers panel
sw.js cache by browser (default)
Up to 24 hours — use Cache-Control: no-cache

skipWaiting + clients.claim()

To activate immediately without waiting for tabs to close:

// install handler
self.skipWaiting();

// activate handler
self.addEventListener('activate', event => {
  event.waitUntil(clients.claim());
});

skipWaiting() — new worker activates immediately, even with open pages. clients.claim() — new worker takes control of all open pages in its scope.

Use this deliberately. Without content-hashed asset filenames, skipWaiting is how you ship the version-mismatch failure mode.

Cache strategies

Inside the fetch handler you pick a strategy per request type:

Cache-first — check cache, fall back to network. Right for immutable, content-hashed static assets (app.4f3a1c.js):

event.respondWith(caches.match(event.request).then(r => r || fetch(event.request)));

Network-first — try network, fall back to cache on failure. Right for API responses that should be fresh but must degrade gracefully offline.

Stale-while-revalidate — serve the cached copy immediately, fetch a fresh copy in the background to update the cache for next time. Right for content that can be slightly stale (avatars, feeds).

Network-only and cache-only are the degenerate ends.

Libraries like Workbox give these strategies declaratively with route matching, but the strategies themselves are ten lines of fetch-handler code.

Order the steps

Order the service worker lifecycle events from registration to steady state.

  1. 1 register(): browser downloads and parses sw.js
  2. 2 install event: pre-cache the app shell
  3. 3 waiting: new worker idles until old pages close (unless skipWaiting)
  4. 4 activate event: clean up old caches
  5. 5 fetch events: intercept and respond to requests
Quiz

You deploy a new service worker, reload the page, and still get the old behaviour. Why?

Quiz

Which job is a service worker the right tool for?

Trace it
1/4

A page enables COOP + COEP to use multithreaded WASM. After deploy, the WASM module fails to start and the page is missing its hero image and a third-party analytics script. What happened?

1
Step 1 of 4
COEP: require-corp blocked the cross-origin hero image and analytics script because they do not send Cross-Origin-Resource-Policy headers
2
Locked
The WASM binary is corrupt
3
Locked
The browser does not support SharedArrayBuffer
4
Locked
The service worker cached a stale WASM file
Why this works

Why does the waiting state exist? Consider: you deploy version N+1 of your app. An open tab loaded version N’s HTML and has version N’s worker. If the new worker activated immediately, it would start serving version N+1’s cached assets to a page that was expecting version N’s assets — a version mismatch that could break the page mid-session. The waiting state prevents this: a user finishing work in an open tab will complete their session with a consistent asset set. skipWaiting is the escape hatch when you are confident your assets are content-hashed (old and new can coexist) and you want the new worker active immediately.

Recall before you leave
  1. 01
    Walk through the service worker lifecycle from register() to handling fetch events.
  2. 02
    What is the waiting state and how do you override it?
  3. 03
    What are the three main cache strategies and when do you use each?
Recap

A service worker intercepts every fetch request in its scope once activated. The lifecycle is: register, install (pre-cache), waiting (new worker idles until old tabs close), activate (clean old caches), then the idle/running fetch loop. event.waitUntil(promise) in any handler prevents the browser from killing the worker mid-operation. skipWaiting() + clients.claim() overrides the waiting state, activating immediately — safe only with content-hashed assets. Service workers have no persistent global state; the browser kills them aggressively and restarts on the next event, so all durable state must live in the Cache API or IndexedDB.

Connected lessons
appears again in143
Continue the climb ↑SharedArrayBuffer, Atomics, and cross-origin isolation
shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.