Service workers & offline apps
Learn how service workers intercept requests, manage their lifecycle, choose caching strategies, show offline fallbacks, and support push or background sync without registering one on this site.
- 01Trace the lifecycleExplain install, waiting, activate, skipWaiting, and clients.claim.
- 02Pick a cache strategyMatch resources to cache-first, network-first, stale-while-revalidate, or network-only.
- 03Design offline behaviorRoute navigations to a fallback page and know what push or sync require.
A tiny server in the browser
A service worker is a JavaScript file the browser can run between pages in its scope and the network. It can intercept requests, answer from cache, fall back to a saved offline page, receive push events, and retry queued work later.
This site has no lesson service worker. The experiments below are faithful in-page simulations plus read-only feature detection. We do not call navigator.serviceWorker.register() here because a real registration persists and could control future visits.
Imagine every package for a building stops at the front desk. The concierge can forward it to the store, hand over something already in the pantry, or give a helpful note when deliveries are impossible.
- In real life: The building
- In JavaScript: The service worker’s scope
- In real life: Offices in the building
- In JavaScript: Controlled pages or tabs
- In real life: Deliveries arriving
- In JavaScript: Fetch requests
- In real life: The concierge answering from the desk
- In JavaScript: A cached or generated Response
Where the analogy stops: A concierge is always standing there. A real service worker is event-driven and the browser may stop it when idle, so durable state belongs in Cache API or IndexedDB.
This site does not register a service worker. These are read-only browser facts detected after mount.
checkingYou have seen fetch, promises, IndexedDB, and the Cache API. A service worker combines those ideas into a request router that can keep an app useful when the network is unreliable.
Registration & lifecycle
INTERACTIVEReal apps register a worker from page code. The worker then moves through installing, installed/waiting, activating, and activated. A new version usually waits until pages using the old version close.
A responsible building does not swap the front desk mid-delivery unless it chooses an emergency handoff. That is why version two can be installed but waiting while version one still controls open tabs.
- In real life: New concierge trains
- In JavaScript: install event
- In real life: Current shift is still serving offices
- In JavaScript: waiting worker
- In real life: New concierge takes the desk
- In JavaScript: activate event
- In real life: Taking over immediately
- In JavaScript: skipWaiting()
- In real life: Greeting offices already open
- In JavaScript: clients.claim()
Where the analogy stops: A worker can activate without controlling an already-open page unless clients.claim() runs after activation. Activation and control are related, not identical.
register("/sw.js")install event: precache filesactivate event: cleanup old caches// Later: /sw.js changes on the servernew worker installsnew worker waits while old tabs are openskipWaiting(); // optional immediate handoffclients.claim(); // optional control for open pagesnonenonenonenone- No service worker is registered for this simulated scope.
This is a tested pure state machine, not a real registration. Try the normal path first: register v1, finish it, deploy v2, finish it, then close all tabs.
skipWaiting() activates a waiting worker. clients.claim() makes open pages controlled after activation.Service workers require HTTPS, except on localhost for development. The default scope is the script’s directory: /app/sw.js controls /app/ by default. Browsers check for updates on navigation to a controlled page and periodically, at least about every 24 hours.
if ("serviceWorker" in navigator && isSecureContext) { const registration = await navigator.serviceWorker.register("/sw.js", { scope: "/app/", }); registration.addEventListener("updatefound", () => { const next = registration.installing; next?.addEventListener("statechange", () => { if (next.state === "installed" && navigator.serviceWorker.controller) { showUpdateReadyMessage(); } }); });}Fetch events and scope
STEP THROUGHOnce active and controlling a page, the worker receives fetch events for requests in scope. The key rule is precise: event.respondWith() must be called synchronously inside the handler. The promise you pass can do asynchronous cache and network work afterward.
Choose a network condition, then step through the exact fetch route a navigation request takes.
script
const request = event.request; if (request.mode === "navigate") { event.respondWith(networkFirst(request, "/offline.html")); return; } if (request.destination === "style" || request.destination === "script") { event.respondWith(cacheFirst(request)); return; } event.respondWith(staleWhileRevalidate(request));});self.addEventListener("fetch", (event) => { const request = event.request; if (request.mode === "navigate") { event.respondWith(networkFirst(request, "/offline.html")); return; } if (request.destination === "style" || request.destination === "script") { event.respondWith(cacheFirst(request)); return; } event.respondWith(staleWhileRevalidate(request));});Notice the early return after each respondWith(). A request gets one response decision. Also notice what is missing: there is no document, no DOM, and no React state inside the worker.
Caching strategies
INTERACTIVEThe hardest part of offline apps is not writing cache.put(). It is choosing when old data is acceptable and when a live answer matters.
- In real life: Grab it from the pantry first
- In JavaScript: cache-first
- In real life: Call the store first, pantry if the store is closed
- In JavaScript: network-first
- In real life: Serve pantry food now, restock in the background
- In JavaScript: stale-while-revalidate
Where the analogy stops: Food does not have privacy headers, authentication, or correctness rules. Real apps must avoid caching sensitive or always-fresh responses with the wrong strategy.
async function handleRequest(request, strategy) { if (strategy === "cache-first") return cacheFirst(request); if (strategy === "network-first") return networkFirst(request, "/offline.html"); if (strategy === "stale-while-revalidate") return staleWhileRevalidate(request); return fetch(request);}No request yet.
- Choose settings, then request a resource.
No real browser service worker or CacheStorage is used here. The pure model uses real Request and Response objects in tests.
/offline.html when the chosen strategy cannot produce a page.- App shell files: CSS, logo, main JavaScript bundle
- A logged-in dashboard page that should be fresh but needs
/offline.htmlwhen disconnected - User avatars that can be a little old while a new copy downloads
- Analytics beacon that should not be replayed from cache
- A documentation site’s versioned
/assets/app.ab12.css - News feed API data that should prefer the newest items
- Product thumbnail images on a catalog
- Payment request or stock reservation
Sort each resource by the safest default strategy.
Offline fallbacks
Offline support should feel intentional. Precache a small app shell and a clear /offline.html page during install. During activate, delete old cache names so old files do not pile up forever. During fetch, send navigation requests through network-first and fall back to the offline page only when necessary.
const STATIC_CACHE = "app-shell-v3";const OLD_CACHES = ["app-shell-v1", "app-shell-v2"]; self.addEventListener("install", (event) => { event.waitUntil( caches.open(STATIC_CACHE).then((cache) => cache.addAll(["/", "/styles.css", "/offline.html"]), ), );}); self.addEventListener("activate", (event) => { event.waitUntil( Promise.all(OLD_CACHES.map((name) => caches.delete(name))).then(() => self.clients.claim(), ), );});The browser can stop a service worker when it is idle. Cache responses in the Cache API and store structured queues in IndexedDB. Do not rely on a global array to remember important work.
Push & background sync
Push and sync are not magic background JavaScript. Push requires a server, VAPID keys, a subscription, notification permission, and userVisibleOnly. Background sync retries named work later, mainly in Chromium browsers, so feature detection and fallbacks still matter.
self.addEventListener("push", (event) => { event.waitUntil( self.registration.showNotification("New message", { body: event.data?.text() ?? "Open the app to read it.", }), );}); self.addEventListener("sync", (event) => { if (event.tag === "send-outbox") { event.waitUntil(sendQueuedMessagesFromIndexedDB()); }});A common pattern is an outbox: save a draft message in IndexedDB, try sending it, and if the network fails, register a sync event named send-outbox. When sync fires, read IndexedDB and retry.
Where you’ll use this
Use service workers when the app has a useful offline shape: documentation, dashboards with cached data, installable PWAs, field tools, or forms that can queue work. Start small: cache the app shell, add an offline page, then choose strategies resource by resource.
- Version static assets and serve them cache-first.
- Use network-first for HTML and freshness-sensitive API data.
- Use stale-while-revalidate for images and content that can be briefly old.
- Keep payments, analytics, and permission checks network-only or explicitly queued.
Common misconceptions
| Feature | Service worker | Web worker | Page script |
|---|---|---|---|
| Main job | Intercept requests, cache responses, receive push/sync events | Run CPU work away from the main thread | Read and change the DOM |
| Can use the DOM? | No DOM access | No DOM access | Yes, it owns the page UI |
| Lifetime | Can start for an event and stop when idle | Runs while the owning page keeps it | Runs while the page is loaded |
| Storage for durable state | Cache API and IndexedDB | IndexedDB or messages to the page | DOM state, memory, storage APIs |
- “A registered worker controls everything.” Scope limits control, and an active worker may not control already-open pages unless claimed.
- “Install means active.” A new worker can be installed and waiting.
- “I can call respondWith later.” The call must happen synchronously in the fetch handler.
- “Globals are safe storage.” The browser can stop the worker; use Cache API or IndexedDB.
- “Workers can update the DOM.” Service workers have no DOM. Send messages to pages or respond with different resources.
Practice exercises
4 EXERCISESRun the code mentally before using the answer checker.
const events = [];
events.push("install v1");
events.push("activate v1");
events.push("install v2");
events.push("v2 waits");
console.log(events.join(" -> "));const events = [];
events.push("install v1");
events.push("activate v1");
events.push("install v2");
events.push("v2 waits");
console.log(events.join(" -> "));The array receives four strings in order, then join places arrows between them.
Choose the strategy for a fingerprinted CSS file.
const resource = "versioned CSS";
const strategy = resource === "versioned CSS" ? "cache-first" : "network-first";
console.log(strategy);const resource = "versioned CSS";
const strategy = resource === "versioned CSS" ? "cache-first" : "network-first";
console.log(strategy);A versioned static asset is safe to serve from cache first because updates use a new URL.
Explain why the original handler is unreliable, then compare with the solution.
self.addEventListener("fetch", (event) => {
setTimeout(() => {
event.respondWith(fetch(event.request));
}, 0);
});self.addEventListener("fetch", (event) => {
event.respondWith(fetch(event.request));
});respondWith() must be called synchronously while handling the fetch event. Passing fetch(event.request) is fine because that promise resolves later.
Design a strategy list for a notes app that should open offline, show recent notes if available, and send edits later.
app shell: cache-first
HTML navigation: network-first + /offline.html
recent API data: network-first
avatars: stale-while-revalidate
analytics/payment: network-only
outbox writes: IndexedDB + optional background syncThe plan matches freshness needs to strategies and puts queued writes in durable storage instead of a service-worker global.
Quiz
8 QUESTIONSQuestion 1 of 8What is the safest description of a service worker?
Choose an answer to see the explanation.
Question 2 of 8What does this lifecycle log show?
Read the code, then predictconst events = []; events.push("install v1"); events.push("activate v1"); events.push("install v2"); events.push("v2 waits"); console.log(events.join(" -> "));Choose an answer to see the explanation.
Question 3 of 8Which fact about scope is true?
Choose an answer to see the explanation.
Question 4 of 8What does
respondWith()need?Choose an answer to see the explanation.
Question 5 of 8Which strategy fits versioned app-shell files best?
Read the code, then predictconst resource = "versioned CSS"; const strategy = resource === "versioned CSS" ? "cache-first" : "network-first"; console.log(strategy);Choose an answer to see the explanation.
Question 6 of 8Why should a service worker not keep important state in globals?
Choose an answer to see the explanation.
Question 7 of 8What do push notifications require?
Choose an answer to see the explanation.
Question 8 of 8Which statement about this site is true?
Choose an answer to see the explanation.
Key takeaways
- A service worker is a scoped request interceptor with an event-driven lifetime.
- Install prepares, waiting protects old tabs, activate cleans up, and control may need
clients.claim(). - Pick strategies by freshness, safety, and offline usefulness.
- Call
respondWith()synchronously, then let promises do asynchronous work. - Push needs server infrastructure; background sync needs feature detection and durable queued data.
Final definition.
A service worker is a scoped, event-driven browser worker that can answer network requests from cache, network, or fallbacks so an app stays useful offline.
Up next: History & navigation.