Service Workers
A service worker is a JavaScript file that runs in the background, separate from your app. It intercepts network requests, enabling offline support, background sync, and push notifications.
Lifecycle
Register → Install → Activate → Idle ↔ Fetch
↓
Terminated
- Register — your app registers the service worker
- Install — SW downloads and caches assets
- Activate — SW takes control (old SW is replaced)
- Fetch — SW intercepts every network request
Caching Strategies
Cache First (offline-first)
Serve from cache, fall back to network. Best for: static assets, app shell.
self.addEventListener('fetch', (event: FetchEvent) => {
event.respondWith(
caches.open('v1').then(async (cache) => {
const cached = await cache.match(event.request);
if (cached) return cached;
const response = await fetch(event.request);
cache.put(event.request, response.clone());
return response;
})
);
});
Network First (fresh data)
Try network, fall back to cache. Best for: API responses, dynamic content.
async function networkFirst(request: Request): Promise<Response> {
const cache = await caches.open('api-cache');
try {
const response = await fetch(request);
cache.put(request, response.clone());
return response;
} catch {
const cached = await cache.match(request);
if (cached) return cached;
return new Response('Offline', { status: 503 });
}
}
Stale While Revalidate
Serve cache immediately, update cache in background. Best for: images, fonts, non-critical data.
async function staleWhileRevalidate(request: Request): Promise<Response> {
const cache = await caches.open('assets');
const cached = await cache.match(request);
const networkPromise = fetch(request).then(response => {
cache.put(request, response.clone());
return response;
});
return cached ?? networkPromise;
}
Writing a Service Worker
// public/sw.ts (will be at /sw.js after build)
const CACHE_NAME = 'app-v1';
const PRECACHE_URLS = [
'/',
'/index.html',
'/offline.html',
];
// Install — precache the app shell
self.addEventListener('install', (event: ExtendableEvent) => {
event.waitUntil(
caches.open(CACHE_NAME).then(cache => cache.addAll(PRECACHE_URLS))
);
(self as ServiceWorkerGlobalScope).skipWaiting();
});
// Activate — clean up old caches
self.addEventListener('activate', (event: ExtendableEvent) => {
event.waitUntil(
caches.keys().then(cacheNames =>
Promise.all(
cacheNames
.filter(name => name !== CACHE_NAME)
.map(name => caches.delete(name))
)
)
);
(self as ServiceWorkerGlobalScope).clients.claim();
});
// Fetch — route requests
self.addEventListener('fetch', (event: FetchEvent) => {
const { request } = event;
const url = new URL(request.url);
// App shell — cache first
if (url.pathname === '/' || url.pathname.endsWith('.html')) {
event.respondWith(networkFirst(request));
return;
}
// Static assets — cache first
if (request.destination === 'script' || request.destination === 'style') {
event.respondWith(cacheFirst(request));
return;
}
// API calls — network first
if (url.pathname.startsWith('/api/')) {
event.respondWith(networkFirst(request));
return;
}
// Default — stale while revalidate
event.respondWith(staleWhileRevalidate(request));
});
Registering the Service Worker
// src/main.tsx
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js').then(
(registration) => console.log('SW registered:', registration.scope),
(error) => console.error('SW registration failed:', error)
);
});
}
Push Notifications
// Request permission
const permission = await Notification.requestPermission();
if (permission !== 'granted') return;
// Subscribe to push
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(VAPID_PUBLIC_KEY),
});
// Send subscription to server
await fetch('/api/push/subscribe', {
method: 'POST',
body: JSON.stringify(subscription),
});
// In service worker — handle push
self.addEventListener('push', (event: PushEvent) => {
const data = event.data?.json();
event.waitUntil(
(self as ServiceWorkerGlobalScope).registration.showNotification(
data.title,
{ body: data.body, icon: '/icons/icon-192.png' }
)
);
});
Background Sync
Queue actions when offline, replay when online:
// Register sync
async function saveOfflineAction(data: unknown) {
const db = await openDB('actions', 1);
await db.add('pending', data);
await navigator.serviceWorker.ready;
await (await navigator.serviceWorker.ready).sync.register('sync-actions');
}
// In service worker
self.addEventListener('sync', (event: SyncEvent) => {
if (event.tag === 'sync-actions') {
event.waitUntil(syncPendingActions());
}
});