Complete guide with code examples — registration, lifecycle, caching strategies, push notifications, background sync, and Cache API.
Service workers must be registered from a page. They run in a separate thread and have a defined lifecycle: install → activated → fetch.
if ('serviceWorker' in navigator) {
window.addEventListener('load', async () => {
try {
const registration = await navigator.serviceWorker.register('/sw.js', {
scope: '/'
});
console.log('SW registered:', registration.scope);
} catch (error) {
console.error('SW registration failed:', error);
}
});
}
const registration = await navigator.serviceWorker.getRegistration();
if (registration) {
console.log('Active SW:', registration.active?.state);
console.log('Scope:', registration.scope);
} else {
console.log('No service worker registered');
}
const registration = await navigator.serviceWorker.getRegistration();
if (registration) {
const success = await registration.unregister();
console.log('Unregistered:', success);
}
const registration = await navigator.serviceWorker.register('/sw.js');
registration.addEventListener('updatefound', () => {
const newWorker = registration.installing;
console.log('New SW found, state:', newWorker.state);
newWorker.addEventListener('statechange', () => {
if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
// New content available, prompt user to refresh
console.log('New version available; refresh to update.');
}
});
});
// Lifecycle states: "installing" → "installed" → "activating" → "activated" → "redundant"
// Use skipWaiting() and clients.claim() to activate immediately.
self.addEventListener('install', (event) => {
console.log('Installing...');
// Force activation without waiting for existing tabs to close
self.skipWaiting();
});
self.addEventListener('activate', (event) => {
console.log('Activating...');
// Take control of all pages immediately
event.waitUntil(self.clients.claim());
});
The install event fires once when the SW is first registered. The activate event fires when the SW takes control.
const CACHE_NAME = 'app-v1';
const PRECACHE_URLS = [
'/',
'/index.html',
'/styles.css',
'/app.js',
'/offline.html'
];
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => {
console.log('Pre-caching assets');
return cache.addAll(PRECACHE_URLS);
})
);
self.skipWaiting();
});
const CACHE_NAME = 'app-v2'; // Bumped version
self.addEventListener('activate', (event) => {
const ALLOWED_CACHES = [CACHE_NAME];
event.waitUntil(
caches.keys().then((cacheNames) => {
return Promise.all(
cacheNames
.filter((name) => !ALLOWED_CACHES.includes(name))
.map((name) => {
console.log('Deleting old cache:', name);
return caches.delete(name);
})
);
}).then(() => self.clients.claim())
);
});
const VERSION = 'v3';
const STATIC_CACHE = `static-${VERSION}`;
const DYNAMIC_CACHE = `dynamic-${VERSION}`;
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(STATIC_CACHE).then((cache) => cache.addAll([
'/',
'/app.js'
]))
);
});
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((keys) =>
Promise.all(
keys
.filter((k) => !k.endsWith(VERSION))
.map((k) => caches.delete(k))
)
)
);
self.clients.claim();
});
The fetch event intercepts all network requests made by the page. Call event.respondWith() to provide a custom response.
self.addEventListener('fetch', (event) => {
event.respondWith(
caches.match(event.request).then((cachedResponse) => {
// Return cached version or fetch from network
return cachedResponse || fetch(event.request);
})
);
});
self.addEventListener('fetch', (event) => {
// Only intercept GET requests
if (event.request.method !== 'GET') return;
event.respondWith(
caches.match(event.request).then((cached) => {
return cached || fetch(event.request);
})
);
});
self.addEventListener('fetch', (event) => {
event.respondWith(
fetch(event.request).catch(() => {
// For navigation requests, show offline page
if (event.request.mode === 'navigate') {
return caches.match('/offline.html');
}
// For other requests, try the cache
return caches.match(event.request);
})
);
});
self.addEventListener('fetch', (event) => {
const url = new URL(event.request.url);
// API requests → network-first
if (url.pathname.startsWith('/api/')) {
event.respondWith(networkFirst(event.request));
return;
}
// Static assets → cache-first
if (url.pathname.match(/\.(js|css|png|jpg|svg|woff2)$/)) {
event.respondWith(cacheFirst(event.request));
return;
}
// Everything else → stale-while-revalidate
event.respondWith(staleWhileRevalidate(event.request));
});
Check the cache first. If found, return it. Otherwise fetch from network and cache the response.
async function cacheFirst(request) {
const cached = await caches.match(request);
if (cached) return cached;
try {
const response = await fetch(request);
if (response.ok) {
const cache = await caches.open('static-v1');
cache.put(request, response.clone());
}
return response;
} catch (error) {
// Optional: return a fallback response
return new Response('Offline', { status: 503 });
}
}
Try the network first. If it fails, fall back to cache. Best for always-fresh data.
async function networkFirst(request) {
try {
const response = await fetch(request);
if (response.ok) {
const cache = await caches.open('dynamic-v1');
cache.put(request, response.clone());
}
return response;
} catch (error) {
const cached = await caches.match(request);
if (cached) return cached;
return new Response(JSON.stringify({ error: 'Offline' }), {
headers: { 'Content-Type': 'application/json' },
status: 503
});
}
}
Return cached response immediately (if available), then fetch and update the cache in the background. User gets fast response + fresh data on next visit.
async function staleWhileRevalidate(request) {
const cache = await caches.open('dynamic-v1');
const cached = await cache.match(request);
const fetchPromise = fetch(request).then((response) => {
if (response.ok) {
cache.put(request, response.clone());
}
return response;
}).catch(() => cached);
// Return cache immediately, or wait for network if not cached
return cached || fetchPromise;
}
async function networkOnly(request) {
return fetch(request);
}
async function cacheOnly(request) {
const cached = await caches.match(request);
return cached || new Response('Not found', { status: 404 });
}
| Strategy | Freshness | Speed | Offline | Best For |
|---|---|---|---|---|
| Cache First | Low | Fastest | Yes | Static assets, fonts, images |
| Network First | Highest | Slower | Yes | API calls, user data |
| Stale-While-Revalidate | Medium | Fast | Yes | Mixed content, best UX |
The Cache API is available in both the service worker and the main thread (via window.caches).
const cache = await caches.open('my-cache');
// Add a single URL
await cache.add('/styles.css');
// Add multiple URLs
await cache.addAll(['/app.js', '/logo.png', '/manifest.json']);
// Add with a custom request
await cache.put(
new Request('/api/data', { headers: { 'Authorization': 'Bearer ...' } }),
new Response(JSON.stringify({ hello: 'world' }), {
headers: { 'Content-Type': 'application/json' }
})
);
// Match a specific request
const response = await caches.match('/styles.css');
if (response) {
const text = await response.text();
console.log('Cached CSS length:', text.length);
}
// Match within a named cache
const cache = await caches.open('my-cache');
const entry = await cache.match('/api/data');
// Delete a specific entry
await cache.delete('/old-page.html');
// Delete an entire cache
await caches.delete('my-cache');
// List all cache names
const names = await caches.keys();
console.log('Caches:', names);
// List all entries in a cache
const cache = await caches.open('my-cache');
const requests = await cache.keys();
console.log(`Entries (${requests.length}):`);
for (const req of requests) {
console.log(` ${req.method} ${req.url}`);
}
// Get cache storage estimate
const estimate = await navigator.storage.estimate();
console.log(`Used: ${(estimate.usage / 1024 / 1024).toFixed(2)} MB`);
console.log(`Quota: ${(estimate.quota / 1024 / 1024).toFixed(0)} MB`);
// You can use the Cache API outside the service worker
async function prefetchPage(url) {
const cache = await caches.open('prefetch');
const response = await fetch(url);
await cache.put(url, response);
console.log('Prefetched:', url);
}
// Usage: prefetch pages the user is likely to visit
document.querySelectorAll('a[rel="prefetch"]').forEach((link) => {
link.addEventListener('mouseenter', () => prefetchPage(link.href), { once: true });
});
Push requires a VAPID key pair and a push service subscription. The service worker listens for push events.
const VAPID_PUBLIC_KEY = 'BEl62iUYgUivxIkv69yViEuiBIa-...(your public key)';
async function subscribeToPush() {
const permission = await Notification.requestPermission();
if (permission !== 'granted') {
console.log('Notification permission denied');
return;
}
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(VAPID_PUBLIC_KEY)
});
// Send subscription to your server
await fetch('/api/push/subscribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(subscription.toJSON())
});
console.log('Subscribed:', subscription.endpoint);
}
// Helper: convert VAPID key
function urlBase64ToUint8Array(base64String) {
const padding = '='.repeat((4 - base64String.length % 4) % 4);
const base64 = (base64String + padding)
.replace(/-/g, '+').replace(/_/g, '/');
const rawData = atob(base64);
return Uint8Array.from([...rawData].map((c) => c.charCodeAt(0)));
}
self.addEventListener('push', (event) => {
let data = { title: 'Notification', body: 'You have a new message.' };
if (event.data) {
try {
data = event.data.json();
} catch {
data.body = event.data.text();
}
}
const options = {
body: data.body,
icon: '/icon-192.png',
badge: '/badge-72.png',
vibrate: [100, 50, 100],
data: {
url: data.url || '/'
},
actions: [
{ action: 'open', title: 'Open' },
{ action: 'dismiss', title: 'Dismiss' }
]
};
event.waitUntil(
self.registration.showNotification(data.title, options)
);
});
self.addEventListener('notificationclick', (event) => {
event.notification.close();
if (event.action === 'dismiss') return;
const targetUrl = event.notification.data?.url || '/';
event.waitUntil(
self.clients.matchAll({ type: 'window', includeUncontrolled: true })
.then((clients) => {
// Focus existing window if open
for (const client of clients) {
if (client.url.includes(targetUrl) && 'focus' in client) {
return client.focus();
}
}
// Otherwise open a new window
return self.clients.openWindow(targetUrl);
})
);
});
async function unsubscribeFromPush() {
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.getSubscription();
if (subscription) {
await subscription.unsubscribe();
// Notify server
await fetch('/api/push/unsubscribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ endpoint: subscription.endpoint })
});
console.log('Unsubscribed');
}
}
Background Sync lets you defer actions until the user has stable connectivity. Perfect for offline form submissions, analytics, and queued operations.
async function submitForm(formData) {
if ('serviceWorker' in navigator && 'SyncManager' in window) {
// Store the data in IndexedDB for the SW to pick up
await storeInIndexedDB('outbox', formData);
const registration = await navigator.serviceWorker.ready;
await registration.sync.register('submit-form');
console.log('Sync registered — will fire when online');
} else {
// Fallback: just try to send now
await fetch('/api/submit', {
method: 'POST',
body: JSON.stringify(formData)
});
}
}
self.addEventListener('sync', (event) => {
if (event.tag === 'submit-form') {
event.waitUntil(submitFormData());
}
if (event.tag === 'sync-analytics') {
event.waitUntil(flushAnalytics());
}
});
async function submitFormData() {
const items = await getAllFromIndexedDB('outbox');
for (const item of items) {
try {
const response = await fetch('/api/submit', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(item)
});
if (response.ok) {
await deleteFromIndexedDB('outbox', item.id);
}
} catch (error) {
// Will retry on next sync
console.error('Submit failed:', error);
throw error;
}
}
}
async function registerPeriodicSync() {
const registration = await navigator.serviceWorker.ready;
// Check if periodic sync is supported
if ('periodicSync' in registration) {
const status = await navigator.permissions.query({
name: 'periodic-background-sync'
});
if (status.state === 'granted') {
await registration.periodicSync.register('update-content', {
minInterval: 24 * 60 * 60 * 1000 // Once per day
});
console.log('Periodic sync registered');
}
}
}
// In sw.js:
self.addEventListener('periodicsync', (event) => {
if (event.tag === 'update-content') {
event.waitUntil(updateCacheInBackground());
}
});
async function updateCacheInBackground() {
const cache = await caches.open('content-v1');
await cache.addAll(['/latest-data.json']);
}
// Minimal IndexedDB wrapper for offline queuing
function openDB() {
return new Promise((resolve, reject) => {
const req = indexedDB.open('sw-sync-db', 1);
req.onupgradeneeded = () => {
const db = req.result;
if (!db.objectStoreNames.contains('outbox')) {
db.createObjectStore('outbox', { keyPath: 'id', autoIncrement: true });
}
};
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
}
async function storeInIndexedDB(storeName, data) {
const db = await openDB();
const tx = db.transaction(storeName, 'readwrite');
tx.objectStore(storeName).add(data);
return new Promise((resolve, reject) => {
tx.oncomplete = resolve;
tx.onerror = () => reject(tx.error);
});
}
async function getAllFromIndexedDB(storeName) {
const db = await openDB();
const tx = db.transaction(storeName, 'readonly');
return new Promise((resolve) => {
const req = tx.objectStore(storeName).getAll();
req.onsuccess = () => resolve(req.result);
});
}
async function deleteFromIndexedDB(storeName, id) {
const db = await openDB();
const tx = db.transaction(storeName, 'readwrite');
tx.objectStore(storeName).delete(id);
return new Promise((resolve) => {
tx.oncomplete = resolve;
});
}