Service Worker Caching
Master Service Worker caching — lifecycle management (install, activate, fetch), caching strategies (cache-first, network-first, stale-while-revalidate), offline support, precaching critical assets, runtime caching with Workbox, background sync for offline writes, and cache versioning for safe updates.
When to Use
- The application needs to work offline or in unreliable network conditions
- Repeat visit performance should be instant (sub-100ms) for critical resources
- HTTP caching alone is insufficient because you need programmatic cache control
- Users need to submit forms offline and sync when connectivity returns
- A Progressive Web App (PWA) requires offline capabilities for app store listing
- Static assets should be served from cache without any network request
- API responses should be cached with custom expiration and invalidation logic
- The application needs to show cached content during network failures
- Push notifications require a service worker for background event handling
- You need fine-grained control over which resources are cached and when
Instructions
Register and understand the service worker lifecycle. The lifecycle ensures safe updates without disrupting active pages:
// main.ts — register the service worker
if ('serviceWorker' in navigator) {
window.addEventListener('load', async () => {
const registration = await navigator.serviceWorker.register('/sw.js', {
scope: '/',
});
registration.addEventListener('updatefound', () => {
const newWorker = registration.installing;
newWorker?.addEventListener('statechange', () => {
if (newWorker.state === 'activated') {
// New version active — prompt user to refresh
showUpdateBanner();
}
});
});
});
}
// sw.ts — service worker lifecycle events
const CACHE_VERSION = 'v2';
const PRECACHE_ASSETS = ['/', '/styles.css', '/app.js', '/offline.html'];
// Install: precache critical assets
self.addEventListener('install', (event: ExtendableEvent) => {
event.waitUntil(caches.open(CACHE_VERSION).then((cache) => cache.addAll(PRECACHE_ASSETS)));
self.skipWaiting(); // activate immediately (use with caution)
});
// Activate: clean up old caches
self.addEventListener('activate', (event: ExtendableEvent) => {
event.waitUntil(
caches
.keys()
.then((keys) =>
Promise.all(keys.filter((key) => key !== CACHE_VERSION).map((key) => caches.delete(key)))
)
);
self.clients.claim(); // take control of all pages
});
Implement caching strategies for different resource types.
// Cache-First: best for static assets (JS, CSS, images with content hashes)
async function cacheFirst(request: Request): Promise<Response> {
const cached = await caches.match(request);
if (cached) return cached;
const response = await fetch(request);
if (response.ok) {
const cache = await caches.open(CACHE_VERSION);
cache.put(request, response.clone());
}
return response;
}
// Network-First: best for API data that should be fresh
async function networkFirst(request: Request): Promise<Response> {
try {
const response = await fetch(request);
if (response.ok) {
const cache = await caches.open('api-cache');
cache.put(request, response.clone());
}
return response;
} catch {
const cached = await caches.match(request);
if (cached) return cached;
return new Response('Offline', { status: 503 });
}
}
// Stale-While-Revalidate: best for frequently updated content (feeds, lists)
async function staleWhileRevalidate(request: Request): Promise<Response> {
const cache = await caches.open('swr-cache');
const cached = await cache.match(request);
const fetchPromise = fetch(request).then((response) => {
if (response.ok) {
cache.put(request, response.clone());
}
return response;
});
return cached || fetchPromise;
}
Route requests to appropriate strategies. Use the fetch event to intercept and handle requests:
self.addEventListener('fetch', (event: FetchEvent) => {
const { request } = event;
const url = new URL(request.url);
// Static assets with content hashes: cache-first (immutable)
if (url.pathname.match(/\.(js|css|woff2)$/) && url.pathname.includes('.')) {
event.respondWith(cacheFirst(request));
return;
}
// HTML pages: network-first (always try to get fresh)
if (request.headers.get('accept')?.includes('text/html')) {
event.respondWith(networkFirst(request));
return;
}
// API requests: stale-while-revalidate
if (url.pathname.startsWith('/api/')) {
event.respondWith(staleWhileRevalidate(request));
return;
}
// Images: cache-first
if (request.destination === 'image') {
event.respondWith(cacheFirst(request));
return;
}
// Default: network with cache fallback
event.respondWith(networkFirst(request));
});
Use Workbox for production service workers. Workbox provides battle-tested caching strategies and precaching:
import { precacheAndRoute } from 'workbox-precaching';
import { registerRoute } from 'workbox-routing';
import { CacheFirst, NetworkFirst, StaleWhileRevalidate } from 'workbox-strategies';
import { ExpirationPlugin } from 'workbox-expiration';
import { CacheableResponsePlugin } from 'workbox-cacheable-response';
precacheAndRoute(self.__WB_MANIFEST);
// Images: cache-first, 100 entries, 30 days
registerRoute(
({ request }) => request.destination === 'image',
new CacheFirst({
cacheName: 'images',
plugins: [
new CacheableResponsePlugin({ statuses: [0, 200] }),
new ExpirationPlugin({ maxEntries: 100, maxAgeSeconds: 30 * 24 * 60 * 60 }),
],
})
);
// API: stale-while-revalidate, 50 entries, 5 min
registerRoute(
({ url }) => url.pathname.startsWith('/api/'),
new StaleWhileRevalidate({
cacheName: 'api-responses',
plugins: [
new CacheableResponsePlugin({ statuses: [0, 200] }),
new ExpirationPlugin({ maxEntries: 50, maxAgeSeconds: 300 }),
],
})
);
// Pages: network-first with 3s timeout
registerRoute(
({ request }) => request.mode === 'navigate',
new NetworkFirst({ cacheName: 'pages', networkTimeoutSeconds: 3 })
);
Implement offline fallback pages. For navigation requests, catch fetch failures and serve the cached page or a precached /offline.html. Use event.request.mode === 'navigate' to detect page navigations.
Implement background sync for offline writes. On fetch failure, store the request in IndexedDB and call registration.sync.register('tag'). In the service worker, listen for the sync event, retrieve queued submissions, replay them via fetch, and remove from the queue on success. This enables offline form submission and data sync.
Handle service worker updates safely. Do not call skipWaiting() unconditionally. Instead, show an update banner when registration.waiting is detected, and only call skipWaiting() via postMessage when the user clicks "Update". Listen for controllerchange on the main page to reload once (guard with a refreshing flag to avoid loops).
Details
Service Worker Scope and Lifecycle
A service worker controls all pages within its scope. The lifecycle prevents race conditions: a new worker installs in the background while the old one serves current pages, activating only when all controlled pages close (or skipWaiting() is called). This ensures consistent cached resource versions at the cost of delayed updates.
Worked Example: Twitter Lite PWA
Workbox with layered strategies: cache-first for static assets (precache manifest for atomic updates), stale-while-revalidate for timeline API, cache-first with 100-entry LRU for images. Background sync queues drafts and likes offline. Result: 65% lower data usage on repeat visits, 30% faster perceived load, 75% increase in tweets sent.
Worked Example: Starbucks PWA
Menu and store locator precached at install (~1.5MB). App shell loads from cache in <100ms; personalized content fetches from network. Offline shows full menu from cache. Background sync handles orders during connectivity drops. The PWA is 99.84% smaller than the native iOS app (233KB vs 148MB).
Anti-Patterns
Using skipWaiting() unconditionally. skipWaiting() activates the new worker immediately, potentially serving old cached HTML with new cached JS. This causes version mismatch errors. Use skipWaiting only with a user-initiated refresh prompt.
Caching POST requests or authenticated responses. The Cache API keys on URL only, not request body. Authenticated responses may leak across users on shared devices.
Not setting cache size limits. Without expiration or max entries, caches grow indefinitely. Always use ExpirationPlugin or manual cleanup.
Caching opaque responses without understanding the cost. Chrome allocates 7MB quota per opaque (status 0) response. Use CacheableResponsePlugin to filter by status.
Source
Process
- Read the instructions and examples in this document.
- Apply the patterns to your implementation, adapting to your specific context.
- Verify your implementation against the details and edge cases listed above.
Harness Integration
- Type: knowledge — this skill is a reference document, not a procedural workflow.
- No tools or state — consumed as context by other skills and agents.
Success Criteria
- Critical assets are precached during service worker install for instant repeat visits.
- Caching strategies match resource types (cache-first for static, network-first for HTML, SWR for API).
- Cache size is bounded with expiration policies on all runtime caches.
- An offline fallback page is shown when the network is unavailable.
- Service worker updates are handled safely with user-prompted refresh.
1---2name: perf-service-worker-caching3description: Service Worker Caching4---5# Service Worker Caching67> Master Service Worker caching — lifecycle management (install, activate, fetch), caching strategies (cache-first, network-first, stale-while-revalidate), offline support, precaching critical assets, runtime caching with Workbox, background sync for offline writes, and cache versioning for safe updates.89## When to Use1011- The application needs to work offline or in unreliable network conditions12- Repeat visit performance should be instant (sub-100ms) for critical resources13- HTTP caching alone is insufficient because you need programmatic cache control14- Users need to submit forms offline and sync when connectivity returns15- A Progressive Web App (PWA) requires offline capabilities for app store listing16- Static assets should be served from cache without any network request17- API responses should be cached with custom expiration and invalidation logic18- The application needs to show cached content during network failures19- Push notifications require a service worker for background event handling20- You need fine-grained control over which resources are cached and when2122## Instructions23241. **Register and understand the service worker lifecycle.** The lifecycle ensures safe updates without disrupting active pages:2526 ```typescript27 // main.ts — register the service worker28 if ('serviceWorker' in navigator) {29 window.addEventListener('load', async () => {30 const registration = await navigator.serviceWorker.register('/sw.js', {31 scope: '/',32 });3334 registration.addEventListener('updatefound', () => {35 const newWorker = registration.installing;36 newWorker?.addEventListener('statechange', () => {37 if (newWorker.state === 'activated') {38 // New version active — prompt user to refresh39 showUpdateBanner();40 }41 });42 });43 });44 }45 ```4647 ```typescript48 // sw.ts — service worker lifecycle events49 const CACHE_VERSION = 'v2';50 const PRECACHE_ASSETS = ['/', '/styles.css', '/app.js', '/offline.html'];5152 // Install: precache critical assets53 self.addEventListener('install', (event: ExtendableEvent) => {54 event.waitUntil(caches.open(CACHE_VERSION).then((cache) => cache.addAll(PRECACHE_ASSETS)));55 self.skipWaiting(); // activate immediately (use with caution)56 });5758 // Activate: clean up old caches59 self.addEventListener('activate', (event: ExtendableEvent) => {60 event.waitUntil(61 caches62 .keys()63 .then((keys) =>64 Promise.all(keys.filter((key) => key !== CACHE_VERSION).map((key) => caches.delete(key)))65 )66 );67 self.clients.claim(); // take control of all pages68 });69 ```70712. **Implement caching strategies for different resource types.**7273 ```typescript74 // Cache-First: best for static assets (JS, CSS, images with content hashes)75 async function cacheFirst(request: Request): Promise<Response> {76 const cached = await caches.match(request);77 if (cached) return cached;7879 const response = await fetch(request);80 if (response.ok) {81 const cache = await caches.open(CACHE_VERSION);82 cache.put(request, response.clone());83 }84 return response;85 }8687 // Network-First: best for API data that should be fresh88 async function networkFirst(request: Request): Promise<Response> {89 try {90 const response = await fetch(request);91 if (response.ok) {92 const cache = await caches.open('api-cache');93 cache.put(request, response.clone());94 }95 return response;96 } catch {97 const cached = await caches.match(request);98 if (cached) return cached;99 return new Response('Offline', { status: 503 });100 }101 }102103 // Stale-While-Revalidate: best for frequently updated content (feeds, lists)104 async function staleWhileRevalidate(request: Request): Promise<Response> {105 const cache = await caches.open('swr-cache');106 const cached = await cache.match(request);107108 const fetchPromise = fetch(request).then((response) => {109 if (response.ok) {110 cache.put(request, response.clone());111 }112 return response;113 });114115 return cached || fetchPromise;116 }117 ```1181193. **Route requests to appropriate strategies.** Use the fetch event to intercept and handle requests:120121 ```typescript122 self.addEventListener('fetch', (event: FetchEvent) => {123 const { request } = event;124 const url = new URL(request.url);125126 // Static assets with content hashes: cache-first (immutable)127 if (url.pathname.match(/\.(js|css|woff2)$/) && url.pathname.includes('.')) {128 event.respondWith(cacheFirst(request));129 return;130 }131132 // HTML pages: network-first (always try to get fresh)133 if (request.headers.get('accept')?.includes('text/html')) {134 event.respondWith(networkFirst(request));135 return;136 }137138 // API requests: stale-while-revalidate139 if (url.pathname.startsWith('/api/')) {140 event.respondWith(staleWhileRevalidate(request));141 return;142 }143144 // Images: cache-first145 if (request.destination === 'image') {146 event.respondWith(cacheFirst(request));147 return;148 }149150 // Default: network with cache fallback151 event.respondWith(networkFirst(request));152 });153 ```1541554. **Use Workbox for production service workers.** Workbox provides battle-tested caching strategies and precaching:156157 ```typescript158 import { precacheAndRoute } from 'workbox-precaching';159 import { registerRoute } from 'workbox-routing';160 import { CacheFirst, NetworkFirst, StaleWhileRevalidate } from 'workbox-strategies';161 import { ExpirationPlugin } from 'workbox-expiration';162 import { CacheableResponsePlugin } from 'workbox-cacheable-response';163164 precacheAndRoute(self.__WB_MANIFEST);165166 // Images: cache-first, 100 entries, 30 days167 registerRoute(168 ({ request }) => request.destination === 'image',169 new CacheFirst({170 cacheName: 'images',171 plugins: [172 new CacheableResponsePlugin({ statuses: [0, 200] }),173 new ExpirationPlugin({ maxEntries: 100, maxAgeSeconds: 30 * 24 * 60 * 60 }),174 ],175 })176 );177178 // API: stale-while-revalidate, 50 entries, 5 min179 registerRoute(180 ({ url }) => url.pathname.startsWith('/api/'),181 new StaleWhileRevalidate({182 cacheName: 'api-responses',183 plugins: [184 new CacheableResponsePlugin({ statuses: [0, 200] }),185 new ExpirationPlugin({ maxEntries: 50, maxAgeSeconds: 300 }),186 ],187 })188 );189190 // Pages: network-first with 3s timeout191 registerRoute(192 ({ request }) => request.mode === 'navigate',193 new NetworkFirst({ cacheName: 'pages', networkTimeoutSeconds: 3 })194 );195 ```1961975. **Implement offline fallback pages.** For navigation requests, catch fetch failures and serve the cached page or a precached `/offline.html`. Use `event.request.mode === 'navigate'` to detect page navigations.1981996. **Implement background sync for offline writes.** On fetch failure, store the request in IndexedDB and call `registration.sync.register('tag')`. In the service worker, listen for the `sync` event, retrieve queued submissions, replay them via `fetch`, and remove from the queue on success. This enables offline form submission and data sync.2002017. **Handle service worker updates safely.** Do not call `skipWaiting()` unconditionally. Instead, show an update banner when `registration.waiting` is detected, and only call `skipWaiting()` via `postMessage` when the user clicks "Update". Listen for `controllerchange` on the main page to reload once (guard with a `refreshing` flag to avoid loops).202203## Details204205### Service Worker Scope and Lifecycle206207A service worker controls all pages within its scope. The lifecycle prevents race conditions: a new worker installs in the background while the old one serves current pages, activating only when all controlled pages close (or `skipWaiting()` is called). This ensures consistent cached resource versions at the cost of delayed updates.208209### Worked Example: Twitter Lite PWA210211Workbox with layered strategies: cache-first for static assets (precache manifest for atomic updates), stale-while-revalidate for timeline API, cache-first with 100-entry LRU for images. Background sync queues drafts and likes offline. Result: 65% lower data usage on repeat visits, 30% faster perceived load, 75% increase in tweets sent.212213### Worked Example: Starbucks PWA214215Menu and store locator precached at install (~1.5MB). App shell loads from cache in <100ms; personalized content fetches from network. Offline shows full menu from cache. Background sync handles orders during connectivity drops. The PWA is 99.84% smaller than the native iOS app (233KB vs 148MB).216217### Anti-Patterns218219**Using skipWaiting() unconditionally.** `skipWaiting()` activates the new worker immediately, potentially serving old cached HTML with new cached JS. This causes version mismatch errors. Use skipWaiting only with a user-initiated refresh prompt.220221**Caching POST requests or authenticated responses.** The Cache API keys on URL only, not request body. Authenticated responses may leak across users on shared devices.222223**Not setting cache size limits.** Without expiration or max entries, caches grow indefinitely. Always use ExpirationPlugin or manual cleanup.224225**Caching opaque responses without understanding the cost.** Chrome allocates 7MB quota per opaque (status 0) response. Use CacheableResponsePlugin to filter by status.226227## Source228229- MDN: Service Worker API — https://developer.mozilla.org/en-US/docs/Web/API/Service_Worker_API230- Workbox — https://developer.chrome.com/docs/workbox/231- web.dev: Service workers and the Cache Storage API — https://web.dev/articles/service-workers-cache-storage232- Jake Archibald: "The Service Worker Lifecycle" — https://web.dev/articles/service-worker-lifecycle233234## Process2352361. Read the instructions and examples in this document.2372. Apply the patterns to your implementation, adapting to your specific context.2383. Verify your implementation against the details and edge cases listed above.239240## Harness Integration241242- **Type:** knowledge — this skill is a reference document, not a procedural workflow.243- **No tools or state** — consumed as context by other skills and agents.244245## Success Criteria246247- Critical assets are precached during service worker install for instant repeat visits.248- Caching strategies match resource types (cache-first for static, network-first for HTML, SWR for API).249- Cache size is bounded with expiration policies on all runtime caches.250- An offline fallback page is shown when the network is unavailable.251- Service worker updates are handled safely with user-prompted refresh.