Browser APIs
17 min read

Service Workers and Cache API

A comprehensive exploration of offline-first web architecture, examining how the Service Worker API (W3C Working Draft, January 2026) enables network interception and background processing, how the Cache API provides fine-grained storage for request/response pairs, and how update flows ensure clients transition safely between versions. These APIs form the foundation of Progressive Web Apps (PWAs): service workers intercept fetches and decide response sources, Cache API stores those responses durably, and the lifecycle model ensures exactly one version controls clients at any time.

Browser Tab

Service Worker

cache-first

network-first

stale-while-revalidate

fetch

Origin Storage

IndexedDB

Page

Fetch Event

Strategy

Cache API

Network

Both

Response

Service workers intercept requests and decide whether to serve from cache, network, or both

Service workers represent a fundamental shift from traditional web architecture: instead of the browser directly fetching resources, an intermediary script can intercept every network request and programmatically decide how to respond.

Key Trade-offs

Design Principles

Event-driven lifecycle

(not document-bound)

Single version per scope

(prevents client inconsistency)

Origin-isolated storage

(Cache + IndexedDB)

Workers terminate between events

No persistent state in memory

Update requires all clients to unload

(unless skipWaiting)

HTTPS required

(prevents MitM cache poisoning)

Core design principles and their operational trade-offs

Mental model:

  • Service Worker acts as a programmable network proxy scoped to a path prefix—it intercepts fetch events for matching URLs and can respond from cache, network, or synthesized responses
  • Cache API stores RequestResponse mappings durably, surviving browser restarts—unlike HTTP cache, you control exactly what’s cached and for how long
  • Lifecycle ensures only one service worker version is active per scope at any time, preventing the “two tabs running different code” problem that plagued AppCache

Critical design decisions:

  • Event-driven with termination: The spec notes “the lifetime of a service worker is tied to the execution lifetime of events”—workers may start and stop “many times a second.” Global state doesn’t persist; use IndexedDB or Cache API
  • Waiting state by default: New workers wait until all clients using the old version close. This prevents mid-session version mismatches but means updates don’t apply immediately
  • HTTPS mandatory: Service workers can intercept any request in their scope, including credentials. Without TLS, attackers could inject malicious workers via MITM

The lifecycle is the most complex part of service workers—and the most important to understand. Unlike regular scripts that run when the page loads, service workers progress through distinct states independently of any document.

navigator.serviceWorker.register()

install event fires

install succeeds

install fails

no existing active worker OR skipWaiting()

existing worker controls clients

all controlled clients close

activate succeeds

activate fails

replaced by new worker

parsed

installing

installed

redundant

activating

waiting

activated

Service worker state transitions—only "activated" workers handle fetch events
StateCan Handle Fetches?Trigger to Next State
parsedNoAutomatic after registration
installingNoinstall event handler completes
installedNoAutomatic (to waiting) or skipWaiting() (to activating)
waitingNoAll clients using old worker unload
activatingNoactivate event handler completes
activatedYesRemains until replaced or unregistered
redundantNoTerminal state—worker is discarded

Registration binds a worker script to a scope URL prefix:

3 collapsed lines
// Feature detection (collapsed)
if (!("serviceWorker" in navigator)) {
console.log("Service workers not supported")
}
// Register with explicit scope
const registration = await navigator.serviceWorker.register("/sw.js", {
scope: "/app/", // Controls /app/* URLs
})
console.log("Scope:", registration.scope) // "https://example.com/app/"
console.log("Active:", registration.active?.state) // "activated" if ready
console.log("Waiting:", registration.waiting?.state) // Worker pending activation
console.log("Installing:", registration.installing?.state) // Currently installing
// Default scope: directory containing the worker script
// /scripts/sw.js → scope defaults to /scripts/
2 collapsed lines
await navigator.serviceWorker.register("/scripts/sw.js")
// Only controls /scripts/* URLs unless Service-Worker-Allowed header expands it

Scope rules:

  • A worker at /sw.js defaults to scope / (entire origin)
  • A worker at /app/sw.js defaults to scope /app/
  • Scope cannot exceed the worker’s directory unless the server sends Service-Worker-Allowed: / header
  • Multiple registrations with different scopes can coexist; most-specific scope wins

The install event fires once per worker version. Use it to cache critical resources:

2 collapsed lines
// Cache version constant (collapsed)
const CACHE_NAME = "app-v1"
self.addEventListener("install", (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => {
// addAll is atomic—fails entirely if any resource fails
return cache.addAll(["/", "/app.js", "/styles.css", "/offline.html"])
}),
)
})
// ⚠️ addAll fetches with mode: 'cors' by default
// Cross-origin resources need proper CORS headers or use:
cache.add(new Request("https://cdn.example.com/lib.js", { mode: "no-cors" }))
// Warning: no-cors responses are "opaque"—you can't inspect status or headers

waitUntil() is critical: The install event would complete immediately without it, potentially activating before caching finishes. If the promise rejects, the worker becomes redundant.

The activate event fires when the worker takes control. Use it to clean old caches:

3 collapsed lines
// Expected cache names (collapsed)
const CURRENT_CACHES = { app: "app-v2", images: "images-v1" }
self.addEventListener("activate", (event) => {
const expectedNames = new Set(Object.values(CURRENT_CACHES))
event.waitUntil(
caches.keys().then((cacheNames) => {
return Promise.all(
cacheNames.map((name) => {
if (!expectedNames.has(name)) {
console.log("Deleting old cache:", name)
return caches.delete(name)
}
}),
)
}),
)
})

Why clean on activate, not install? The old worker may still be serving requests. Deleting its caches during install would break active clients.

Two methods bypass the default waiting behavior:

// In install event: skip waiting state entirely
self.addEventListener("install", (event) => {
self.skipWaiting() // Activate immediately after install
event.waitUntil(/* caching */)
})
// In activate event: take control of existing clients
self.addEventListener("activate", (event) => {
event.waitUntil(
clients.claim(), // Control pages that loaded before this worker
)
})

When to use skipWaiting():

  • Critical bug fixes that must deploy immediately
  • The new version is backward-compatible with pages loaded under the old version

When to avoid it: The spec warns that with skipWaiting(), “new service worker is likely controlling pages that were loaded with an older version.” If your JavaScript expects cached assets that changed, you’ll get broken pages.

clients.claim() use case: First-time installations where pages loaded before registration should immediately get service worker control. Jake Archibald notes: “I rarely do so myself. It only really matters on the very first load.”

Service workers update when:

  1. Navigation to an in-scope page (if >24 hours since last check)
  2. Push/sync event fires (if >24 hours since last check)
  3. registration.update() called explicitly
  4. Worker script URL changes (rare, avoid this pattern)

The browser fetches the worker script and compares bytes. Any difference triggers a new install. The spec notes: “Most browsers default to ignoring caching headers when checking for updates.”

// Trigger manual update check
const registration = await navigator.serviceWorker.ready
await registration.update()
// Monitor for updates
registration.addEventListener("updatefound", () => {
const newWorker = registration.installing
newWorker?.addEventListener("statechange", () => {
if (newWorker.state === "installed") {
// New version ready, waiting to activate
if (registration.active) {
// Show "Update available" UI
showUpdateNotification()
}
}
})
})

The Cache API provides a durable RequestResponse storage mechanism. Unlike HTTP cache, you have explicit control over what’s stored, how matching works, and when entries expire.

caches (global CacheStorage) manages named caches:

MethodPurposeReturns
caches.open(name)Open (or create) a named cachePromise<Cache>
caches.match(req)Search all caches for a matching responsePromise<Response | undefined>
caches.has(name)Check if a cache existsPromise<boolean>
caches.delete(name)Delete a cache and all its entriesPromise<boolean>
caches.keys()List all cache namesPromise<string[]>

Individual Cache objects store request/response pairs:

2 collapsed lines
// Open or create cache (collapsed)
const cache = await caches.open("api-v1")
// Add single resource (fetches and stores)
await cache.add("/api/config")
// Add multiple resources (atomic—all or nothing)
await cache.addAll(["/api/config", "/api/user"])
// Put explicit request/response pair
const response = await fetch("/api/data")
await cache.put("/api/data", response.clone()) // Must clone—body consumed
// Match with options
const match = await cache.match(request, {
ignoreSearch: true, // Ignore query string
ignoreMethod: true, // Match regardless of HTTP method
ignoreVary: true, // Ignore Vary header
})
// List all cached requests
const keys = await cache.keys()
// Delete specific entry
const deleted = await cache.delete("/api/old-endpoint")

Critical: Response bodies are single-use. After cache.put(req, response), the response body is consumed. Always response.clone() if you need to return the response to the page.

Cache matching is exact by default—URL, method, and Vary headers must match:

// These are different cache entries:
cache.put("/api?page=1", response1)
cache.put("/api?page=2", response2)
// Match with query string
await cache.match("/api?page=1") // Returns response1
await cache.match("/api?page=2") // Returns response2
await cache.match("/api") // Returns undefined
// Ignore query string
await cache.match("/api?page=1", { ignoreSearch: true }) // Returns first match

Vary header behavior: If a cached response has Vary: Accept-Language, the cache only returns it when the request’s Accept-Language matches the original request’s value. Use ignoreVary: true to bypass this.

Cache storage counts against the origin’s quota:

BrowserDefault QuotaEviction Policy
Chrome60% of disk (5% in incognito)Least Recently Used (LRU) by origin
FirefoxUp to 2GB per eTLD+1LRU when disk pressure
Safari~1GB (prompts for more on desktop)7-day cap on script-writable storage (see below)

Safari’s 7-day limit: Since March 2020, Safari deletes all script-writable storage (IndexedDB, Cache API, service worker registrations) after 7 days without user interaction. This resets when the user visits the site. PWAs added to home screen are exempt.

Check quota with the Storage API:

const estimate = await navigator.storage.estimate()
console.log(`Used: ${estimate.usage} bytes`)
console.log(`Quota: ${estimate.quota} bytes`)
console.log(`Available: ${((estimate.quota! - estimate.usage!) / 1024 / 1024).toFixed(1)} MB`)
// Request persistent storage (immune to automatic eviction)
const persistent = await navigator.storage.persist()
if (persistent) {
console.log("Storage will not be evicted under pressure")
}

The fetch event is where caching strategies execute. Each strategy trades off freshness, speed, and offline capability:

Return cached response immediately; fetch from network only if not cached:

self.addEventListener("fetch", (event) => {
event.respondWith(
caches.match(event.request).then((cached) => {
if (cached) return cached
return fetch(event.request).then((response) => {
// Optionally cache the new response
if (response.ok) {
const clone = response.clone()
caches.open("dynamic").then((cache) => cache.put(event.request, clone))
}
return response
})
}),
)
})

Use for: Static assets (CSS, JS, images) with versioned URLs (app.v2.js). Immutable once deployed.

Trade-off: Fast and offline-capable, but stale until cache is explicitly invalidated.

Try network first; fall back to cache if offline or request fails:

self.addEventListener("fetch", (event) => {
event.respondWith(
fetch(event.request)
.then((response) => {
// Update cache with fresh response
const clone = response.clone()
caches.open("api").then((cache) => cache.put(event.request, clone))
return response
})
.catch(() => {
// Network failed—try cache
return caches.match(event.request)
}),
)
})

Use for: API responses, user data, anything where freshness matters more than speed.

Trade-off: Always fresh when online, but slower (waits for network). Provides offline fallback.

Return cached response immediately, then fetch and update cache in background:

self.addEventListener("fetch", (event) => {
event.respondWith(
caches.open("swr").then((cache) => {
return cache.match(event.request).then((cached) => {
// Always fetch to update cache
const fetchPromise = fetch(event.request).then((response) => {
cache.put(event.request, response.clone())
return response
})
// Return cached immediately, or wait for network if no cache
return cached || fetchPromise
})
}),
)
})

Use for: Frequently-updated content where slight staleness is acceptable (avatars, news feeds, product listings).

Trade-off: Fast response from cache, eventual freshness. Uses more bandwidth (always fetches).

Pass through to network without caching:

self.addEventListener("fetch", (event) => {
event.respondWith(fetch(event.request))
})

Use for: Non-cacheable requests (POST, real-time data, authenticated content with unique tokens).

Only serve from cache; fail if not cached:

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

Use for: Offline-only apps after initial precaching. Rarely used alone.

Resource TypeStrategyRationale
Versioned static assetsCache-firstImmutable; version change = new URL
App shell (HTML)Network-firstStructure may update; offline fallback useful
API dataNetwork-first or SWRFreshness important; offline read useful
User-generated imagesCache-first + lazy loadLarge; rarely changes once uploaded
Analytics/trackingNetwork-onlyMust reach server; no value in caching
Third-party scriptsStale-while-revalidateUpdates occasionally; speed matters

Navigation preload solves a performance problem: when a user navigates, the browser must start the service worker before dispatching the fetch event. This startup delay (50-500ms) adds latency to every page load.

Navigation preload starts the network request in parallel with worker startup:

NetworkService WorkerBrowserNetworkService WorkerBrowserWithout Navigation PreloadWith Navigation PreloadparStart worker (50-500ms)fetch()ResponseResponseStart workerPreload requestpreloadResponseResponse
Navigation preload eliminates serial worker startup + fetch delay
self.addEventListener("activate", (event) => {
event.waitUntil(
(async () => {
if (self.registration.navigationPreload) {
await self.registration.navigationPreload.enable()
}
})(),
)
})
self.addEventListener("fetch", (event) => {
if (event.request.mode === "navigate") {
event.respondWith(
(async () => {
// Try cache first
const cached = await caches.match(event.request)
if (cached) return cached
// Use preloaded response if available
const preloaded = await event.preloadResponse
if (preloaded) return preloaded
// Fallback to regular fetch
return fetch(event.request)
})(),
)
}
})

Navigation preload requests include the Service-Worker-Navigation-Preload header (default value: true). Servers can customize responses:

// Set custom header value
await registration.navigationPreload.setHeaderValue("v2")
// Server receives: Service-Worker-Navigation-Preload: v2
// Check current state
const state = await registration.navigationPreload.getState()
// { enabled: true, headerValue: "v2" }

Server optimization: Return minimal content (header + footer) for preload, let the service worker merge with cached content.

Important: If your response differs based on this header, include Vary: Service-Worker-Navigation-Preload to prevent caching issues.


Managing updates is where service workers get tricky. The default behavior—waiting until all clients close—is safest but frustrating for users who keep tabs open.

The simplest approach: don’t use skipWaiting(). Users get the update on their next visit after all tabs close.

// No skipWaiting—new worker waits
self.addEventListener("install", (event) => {
event.waitUntil(
caches.open("v2").then((cache) =>
cache.addAll([
/* ... */
]),
),
)
// Worker enters "waiting" state after install
})

Pros: Safe—no version mismatches. Cons: Updates may never apply for users with persistent tabs.

Use skipWaiting() only when user explicitly requests the update:

sw.js
self.addEventListener("message", (event) => {
if (event.data?.type === "SKIP_WAITING") {
self.skipWaiting()
}
})
page.js
3 collapsed lines
// UI notification (collapsed)
function showUpdateNotification(onUpdate: () => void) {
/* ... */
}
const registration = await navigator.serviceWorker.ready
registration.addEventListener("updatefound", () => {
const newWorker = registration.installing!
newWorker.addEventListener("statechange", () => {
if (newWorker.state === "installed" && registration.active) {
// New version ready—show UI
showUpdateNotification(() => {
newWorker.postMessage({ type: "SKIP_WAITING" })
})
}
})
})
5 collapsed lines
// Reload when new worker takes control
navigator.serviceWorker.addEventListener("controllerchange", () => {
window.location.reload()
})

Pros: User controls when the update happens. Cons: Requires UI implementation; some users ignore update prompts.

If new code can run with old-cached assets (or vice versa), skip immediately:

self.addEventListener("install", (event) => {
self.skipWaiting()
event.waitUntil(/* caching */)
})
self.addEventListener("activate", (event) => {
event.waitUntil(clients.claim())
})

Pros: Updates apply immediately. Cons: Risk of version mismatches. Only works if your code handles this gracefully.

When skipWaiting() activates a new worker while old pages are open:

ScenarioResult
Page requests cached JSOld worker’s cache may be deleted; request fails or returns new version
New worker returns new HTMLHTML expects new JS; cached old JS breaks
API response format changedNew worker parses differently than old page expects

Mitigation strategies:

  1. Versioned URLs: app.v2.js never conflicts with app.v1.js
  2. Keep old caches during activate: Don’t delete immediately
  3. Force reload on controller change: location.reload() after controllerchange

A robust offline experience requires graceful degradation when resources aren’t available.

const OFFLINE_PAGE = "/offline.html"
self.addEventListener("install", (event) => {
event.waitUntil(caches.open("offline").then((cache) => cache.add(OFFLINE_PAGE)))
})
self.addEventListener("fetch", (event) => {
if (event.request.mode === "navigate") {
event.respondWith(fetch(event.request).catch(() => caches.match(OFFLINE_PAGE)))
}
})
const OFFLINE_IMAGE = "/images/offline-placeholder.svg"
self.addEventListener("fetch", (event) => {
if (event.request.destination === "image") {
event.respondWith(
caches.match(event.request).then((cached) => {
return cached || fetch(event.request).catch(() => caches.match(OFFLINE_IMAGE))
}),
)
}
})

Queue failed mutations for retry when online:

page.js
3 collapsed lines
// Queue the request for background sync
async function queueForSync(request: Request) {
const queue = await getRequestQueue() // IndexedDB-backed
await queue.push(request)
await navigator.serviceWorker.ready.then((reg) => reg.sync.register("sync-requests"))
}
// Use when fetch fails
try {
await fetch("/api/submit", { method: "POST", body: data })
} catch {
await queueForSync(new Request("/api/submit", { method: "POST", body: data }))
showToast("Saved offline—will sync when connected")
}
sw.js
self.addEventListener("sync", (event) => {
if (event.tag === "sync-requests") {
event.waitUntil(processQueue())
}
})
async function processQueue() {
const queue = await getRequestQueue()
for (const request of await queue.getAll()) {
try {
await fetch(request)
await queue.remove(request)
} catch {
// Still offline—will retry on next sync
break
}
}
}

Chrome DevTools (Application tab):

  • Service Workers panel: View registered workers, state, and messages
  • Update on reload: Forces new worker on every page load (development only)
  • Bypass for network: Disables service worker for all fetches
  • Offline checkbox: Simulates offline mode
  • Cache Storage: Inspect cached request/response pairs

Firefox DevTools:

  • Application > Service Workers: Similar to Chrome
  • Enable Service Workers over HTTP: Testing without HTTPS (toolbox open only)
const DEBUG = true
function log(context: string, ...args: unknown[]) {
if (DEBUG) {
console.log(`[SW:${context}]`, ...args)
}
}
self.addEventListener("install", (event) => {
log("install", "Starting install...")
// ...
})
self.addEventListener("fetch", (event) => {
log("fetch", event.request.method, event.request.url)
// ...
})
SymptomCauseSolution
Changes not appearingOld worker still activeClose all tabs or use Update on reload
Worker stuck in “waiting”Clients still using old workerUse skipWaiting or close tabs
Fetch handler not firingRequest outside scope or no-cors opaqueCheck scope; ensure fetch listener returns
Cache match returns undefinedURL mismatch (query string, trailing slash)Use ignoreSearch: true or normalize URLs
”The service worker navigation preload request was cancelled”Preload unusedAlways consume event.preloadResponse when enabled
Storage quota exceededToo much cached dataImplement cache eviction; check storage.estimate()
self.addEventListener("fetch", (event) => {
event.respondWith(
handleFetch(event.request).catch((error) => {
console.error("Fetch handler error:", error)
// Return appropriate fallback based on request type
if (event.request.destination === "document") {
return caches.match("/offline.html")
}
if (event.request.destination === "image") {
return caches.match("/offline-placeholder.svg")
}
// For other requests, let the error propagate
return new Response("Service unavailable", { status: 503 })
}),
)
})

Workbox (Google) provides battle-tested implementations of caching patterns. Use it unless you have specific requirements that demand custom code.

import { registerRoute } from "workbox-routing"
import { CacheFirst, NetworkFirst, StaleWhileRevalidate } from "workbox-strategies"
import { ExpirationPlugin } from "workbox-expiration"
// Static assets: cache-first with expiration
registerRoute(
({ request }) => request.destination === "style" || request.destination === "script",
new CacheFirst({
cacheName: "static-v1",
plugins: [new ExpirationPlugin({ maxEntries: 50, maxAgeSeconds: 30 * 24 * 60 * 60 })],
}),
)
// API calls: network-first with 3-second timeout
registerRoute(
({ url }) => url.pathname.startsWith("/api/"),
new NetworkFirst({
cacheName: "api-v1",
networkTimeoutSeconds: 3,
}),
)
// Images: stale-while-revalidate
registerRoute(({ request }) => request.destination === "image", new StaleWhileRevalidate({ cacheName: "images-v1" }))
import { precacheAndRoute } from "workbox-precaching"
// Injected by build tool (Webpack, Vite, etc.)
precacheAndRoute(self.__WB_MANIFEST)
// Generates versioned URLs: [{ url: "/app.js", revision: "abc123" }, ...]
  • Complex routing logic not expressible with Workbox’s route matching
  • Custom cache invalidation beyond time/count expiration
  • Streaming responses that need transformation
  • Tight bundle size constraints (Workbox adds ~10-20KB)

Service workers fundamentally change the browser’s network model: instead of direct fetches, an intermediary script decides response sources. The Cache API provides the durable storage that makes offline experiences possible. The lifecycle model—with its waiting states and update flows—ensures clients don’t run inconsistent code.

The key architectural insight is that service workers are event-driven and stateless between events. Global variables don’t persist. The worker may terminate after handling a fetch and restart fresh for the next one. Design accordingly: use Cache API and IndexedDB for persistence, not in-memory state.

Caching strategies represent trade-offs, not best practices. Cache-first sacrifices freshness for speed. Network-first sacrifices speed for freshness. Stale-while-revalidate trades bandwidth for both. Choose based on the specific resource’s characteristics and your users’ expectations.


  • JavaScript Promises and async/await
  • HTTP request/response model (methods, headers, caching headers)
  • Familiarity with the Fetch API
  • Scope: URL path prefix that a service worker controls (e.g., /app/ controls /app/*)
  • Client: A window, worker, or shared worker controlled by a service worker
  • Registration: The binding of a service worker script to a scope
  • Precaching: Caching resources during the install event before they’re needed
  • Opaque response: Cross-origin response with mode: 'no-cors' that hides status and headers
  • Navigation preload: Parallel network request during service worker startup
  • Lifecycle states: parsed → installing → installed → waiting → activating → activated → redundant
  • Only activated workers handle fetch events; waiting workers queue behind the current active worker
  • Cache API stores Request/Response pairs durably; you control expiration and invalidation
  • Caching strategies: cache-first (speed), network-first (freshness), stale-while-revalidate (both with bandwidth cost)
  • skipWaiting() bypasses waiting but risks version mismatches; use with versioned URLs or user prompts
  • Navigation preload eliminates worker startup delay by fetching in parallel
  • Safari’s 7-day limit deletes storage without user interaction; PWAs on home screen are exempt

Specifications (Primary Sources)

  • Service Workers - W3C Working Draft (defines registration, lifecycle, fetch events, cache interface)
  • Fetch Standard - WHATWG Living Standard (defines Request, Response, fetch algorithm)
  • Storage Standard - WHATWG Living Standard (defines quota, persistence, storage buckets)

Official Documentation

Core Maintainer Content

Safari Storage Policy

Browser Support

Read more

  • Previous

    Web Workers and Worklets for Off-Main-Thread Work

    Web Foundations / Browser APIs 15 min read

    Concurrency primitives for keeping the main thread responsive. Workers provide general-purpose parallelism via message passing; worklets integrate directly into the browser’s rendering pipeline for synchronized paint, animation, and audio processing.

  • Next

    Fetch, Streams, and AbortController

    Web Foundations / Browser APIs 20 min read

    A comprehensive exploration of the modern web’s network primitives, examining how the Fetch Standard (WHATWG Living Standard, January 2026) unifies request/response handling across all platform features, how the Streams Standard enables incremental data processing with automatic backpressure, and how AbortController/AbortSignal (DOM Standard Section 3.3) provide composable cancellation semantics. These three APIs form an integrated system: Fetch exposes response bodies as ReadableStreams, Streams propagate backpressure through pipe chains, and AbortSignal enables cancellation at any point in the pipeline.