Service Workers & Offline Strategies
Service workers sit between your application and the network. They intercept every fetch request, enabling cache-first strategies, background sync, and genuine offline support. They're the foundation of Progressive Web Apps.
Lifecycle
Service workers have a strict lifecycle that prevents broken deployments:
Registration â Installation â Waiting â Activation â Controllingif ('serviceWorker' in navigator) {
const registration = await navigator.serviceWorker.register('/sw.js', {
scope: '/',
});
}Installation Phase
The install event fires once when the SW is first registered (or when the file changes). Use it to pre-cache critical resources:
const CACHE_NAME = 'app-v1';
const PRECACHE_URLS = [
'/',
'/styles/main.css',
'/scripts/app.js',
'/offline.html',
];
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => cache.addAll(PRECACHE_URLS))
);
});Activation Phase
The activate event fires after installation when no other SW version is controlling pages. Clean up old caches here:
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((keys) =>
Promise.all(
keys
.filter((key) => key !== CACHE_NAME)
.map((key) => caches.delete(key))
)
)
);
});Skipping the Waiting Phase
By default, a new SW waits until all tabs using the old SW are closed. To activate immediately:
self.addEventListener('install', (event) => {
self.skipWaiting();
event.waitUntil(/* precaching */);
});
self.addEventListener('activate', (event) => {
event.waitUntil(self.clients.claim());
});Warning: This can cause inconsistencies if old-cached pages use new-version assets.
Caching Strategies
Cache First (Cache Falling Back to Network)
Best for: static assets (CSS, JS, images, fonts)
self.addEventListener('fetch', (event) => {
event.respondWith(
caches.match(event.request).then((cached) => {
return cached || fetch(event.request).then((response) => {
const clone = response.clone();
caches.open(CACHE_NAME).then((cache) => cache.put(event.request, clone));
return response;
});
})
);
});Network First (Network Falling Back to Cache)
Best for: API responses, frequently changing content
self.addEventListener('fetch', (event) => {
event.respondWith(
fetch(event.request)
.then((response) => {
const clone = response.clone();
caches.open(CACHE_NAME).then((cache) => cache.put(event.request, clone));
return response;
})
.catch(() => caches.match(event.request))
);
});Stale-While-Revalidate
Best for: content that should be fresh but doesn't need to block rendering
self.addEventListener('fetch', (event) => {
event.respondWith(
caches.open(CACHE_NAME).then((cache) =>
cache.match(event.request).then((cached) => {
const fetchPromise = fetch(event.request).then((response) => {
cache.put(event.request, response.clone());
return response;
});
return cached || fetchPromise;
})
)
);
});Strategy Router
self.addEventListener('fetch', (event) => {
const { request } = event;
const url = new URL(request.url);
if (request.destination === 'image') {
event.respondWith(cacheFirst(request));
} else if (url.pathname.startsWith('/api/')) {
event.respondWith(networkFirst(request));
} else if (request.destination === 'document') {
event.respondWith(networkFirst(request));
} else {
event.respondWith(staleWhileRevalidate(request));
}
});Background Sync
Execute failed operations when connectivity returns:
// App code
async function sendMessage(data) {
try {
await fetch('/api/messages', { method: 'POST', body: JSON.stringify(data) });
} catch {
const registration = await navigator.serviceWorker.ready;
await registration.sync.register('send-messages');
await saveToIndexedDB('pending-messages', data);
}
}
// Service worker
self.addEventListener('sync', (event) => {
if (event.tag === 'send-messages') {
event.waitUntil(
getFromIndexedDB('pending-messages').then((messages) =>
Promise.all(messages.map((msg) =>
fetch('/api/messages', { method: 'POST', body: JSON.stringify(msg) })
))
).then(() => clearIndexedDB('pending-messages'))
);
}
});Push Notifications
// App code â subscribe
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.subscribe({
userVisibleNotification: true,
applicationServerKey: vapidPublicKey,
});
// Service worker â receive
self.addEventListener('push', (event) => {
const data = event.data?.json() ?? {};
event.waitUntil(
self.registration.showNotification(data.title, {
body: data.body,
icon: '/icon-192.png',
badge: '/badge-72.png',
data: { url: data.url },
})
);
});
self.addEventListener('notificationclick', (event) => {
event.notification.close();
event.waitUntil(
clients.openWindow(event.notification.data.url)
);
});Update Strategy
// Check for updates periodically
navigator.serviceWorker.ready.then((registration) => {
setInterval(() => registration.update(), 60 * 60 * 1000);
});
// Notify users of updates
navigator.serviceWorker.addEventListener('controllerchange', () => {
if (confirm('New version available. Reload?')) {
window.location.reload();
}
});Limitations
- HTTPS only â Service workers only work on
localhostand HTTPS origins - No DOM access â They run in a separate thread, communicate via
postMessage - No synchronous APIs â Everything is promise-based
- Scope restriction â A SW at
/app/sw.jscan only control/app/*URLs - Storage limits â Cache Storage is subject to browser storage quotas
Interview Signal
Senior candidates demonstrate:
- Lifecycle mastery â install â waiting â activate,
skipWaiting/claimtrade-offs - Strategy selection â Which caching strategy for which content type, and why
- Offline-first thinking â Background sync, IndexedDB fallback, graceful degradation
- Update management â Cache versioning, old cache cleanup, user notification
- Security model â HTTPS requirement, scope restrictions, same-origin fetch interception only