Service Workers Explained: Offline Capability and Background Sync for Web Apps
Service workers are JavaScript workers that run in the background, separate from the page, and can intercept network requests, cache responses, and perform work even when the page isn’t open. They’re the foundation of progressive web apps (PWAs) — the technology that lets a web app work offline, show push notifications, and install to the home screen. They’re also more opinionated about lifecycle than most browser APIs, and getting caching wrong produces bugs that are frustrating to diagnose because they only appear for users who previously visited your site.
What a Service Worker Can Do
A service worker is a JavaScript file that runs in a separate thread from the page. It has no access to the DOM. It can:
- Intercept
fetchevents (all network requests made by pages it controls) and return cached responses, modified responses, or network responses - Store and retrieve responses from the Cache API
- Receive push messages from a server (even when the page is closed) and show browser notifications
- Run Background Sync — queue actions when offline and retry them when connectivity is restored
- Communicate with pages via
postMessage
Service workers operate only on HTTPS (except localhost). They’re scoped to a path — a service worker registered at /sw.js controls pages under /, while one at /app/sw.js controls only pages under /app/.
The Lifecycle: Install, Activate, Control
The lifecycle is the part of service workers that causes the most confusion. Understanding it is essential for deploying updates correctly.
Install. When the browser downloads a new service worker (or one that has changed by even one byte), it enters the install phase. The install event fires. This is where you pre-cache resources. If event.waitUntil() is called with a promise that rejects, the installation fails and the service worker is discarded.
Waiting. After installation, if there’s an existing active service worker controlling pages, the new service worker waits. It doesn’t take control until all pages controlled by the old service worker are closed. This is the “update stuck waiting” problem that surprises developers — a user with open tabs won’t see the new service worker until they close all tabs for the site.
Activate. Once all old pages are closed, the waiting service worker activates. The activate event fires. This is where you clean up old caches from the previous version. After activation, the service worker controls new page loads.
Control. An active service worker controls pages — it can intercept their fetch events.
One common pattern is calling self.skipWaiting() in the install event and clients.claim() in the activate event to force the new service worker to take control immediately without waiting for old pages to close. Use this carefully: if the new service worker changes caching logic, pages that were loaded by the old service worker may start getting inconsistent responses mid-session.
Caching Strategies in the Fetch Handler
The fetch event intercepts all requests made by controlled pages:
self.addEventListener('fetch', event => {
event.respondWith(handleFetch(event.request));
});
The strategy inside handleFetch determines how resources are served:
Cache first (for static assets):
async function handleFetch(request) {
const cached = await caches.match(request);
return cached ?? fetch(request);
}
Serves from cache if available, falls back to network. Use for static assets with versioned URLs (main.abc123.js). Not appropriate for content that should be fresh.
Network first (for dynamic content):
async function handleFetch(request) {
try {
const response = await fetch(request);
const cache = await caches.open('dynamic-v1');
cache.put(request, response.clone());
return response;
} catch {
return caches.match(request);
}
}
Tries network, caches the result, falls back to cache on failure. Response is cloned because the body stream can only be read once.
Stale-while-revalidate:
async function handleFetch(request) {
const cached = await caches.match(request);
const networkPromise = fetch(request).then(response => {
caches.open('content-v1').then(cache => cache.put(request, response.clone()));
return response;
});
return cached ?? networkPromise;
}
Returns cached content immediately, updates the cache in the background. Fastest perceived performance with eventual freshness.
Precaching at Install Time
Precaching ensures critical resources are available before any user interaction:
const CACHE_VERSION = 'v2';
const PRECACHE_URLS = ['/index.html', '/app.js', '/styles.css', '/offline.html'];
self.addEventListener('install', event => {
event.waitUntil(
caches.open(CACHE_VERSION)
.then(cache => cache.addAll(PRECACHE_URLS))
);
});
cache.addAll() fails atomically if any request fails, which is usually the right behavior — if any precached resource can’t be fetched, don’t install the service worker. For resources where individual failure is acceptable, use Promise.allSettled with individual cache.put calls.
Keep precached URLs in the install handler minimal — large lists slow installation and the service worker won’t activate until all downloads complete.
Cache Cleanup on Activation
Without cleanup, old caches accumulate across service worker versions:
self.addEventListener('activate', event => {
event.waitUntil(
caches.keys().then(cacheNames => {
return Promise.all(
cacheNames
.filter(name => name !== CACHE_VERSION)
.map(name => caches.delete(name))
);
})
);
});
Delete every cache that isn’t the current version. New versions have new names; this automatically cleans up old ones.
Background Sync
Background Sync queues network requests and retries them when connectivity is restored. Useful for fire-and-forget operations (analytics, form submissions) that should complete even if the user goes offline before they do:
// In the page
await navigator.serviceWorker.ready;
await window.SyncManager.register('submit-form');
// In the service worker
self.addEventListener('sync', event => {
if (event.tag === 'submit-form') {
event.waitUntil(submitQueuedForms());
}
});
The sync event fires when connectivity is available, even if the page is closed. The service worker stays alive for the duration of the promise passed to event.waitUntil().
Background Sync is supported in Chrome and Firefox but not Safari. Feature-detect before relying on it.
Push Notifications
Push notifications require a push subscription from the user:
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(publicVapidKey)
});
// Send subscription to your server
The service worker receives push events and shows notifications:
self.addEventListener('push', event => {
const data = event.data.json();
event.waitUntil(
self.registration.showNotification(data.title, {
body: data.body,
icon: '/icon.png'
})
);
});
VAPID (Voluntary Application Server Identification) is the authentication mechanism — your server signs the push message with a private key; the browser verifies against the public key. This prevents sending push notifications to subscriptions you don’t own.
Debugging Service Workers
In Chrome DevTools: Application → Service Workers shows registered service workers, their status, and options to update, skip waiting, or unregister. Cache Storage under the same Application panel shows cached entries.
The console for service worker code appears in the DevTools window you open for the service worker (not the page’s console). Workbox from Google is a library that abstracts common caching strategies and precaching into well-tested primitives — worth using instead of hand-rolling the patterns above for production applications.
FAQ
Why is my update not being seen by users?
The new service worker is installed but waiting for old tabs to close. Either users need to close all tabs with your site, or call self.skipWaiting() in the install handler and clients.claim() in activate. Show an “update available — refresh” prompt in the page to handle this gracefully.
Does a service worker affect every request the page makes? Only requests from pages within the service worker’s scope, and only once the service worker is active and controlling those pages. Requests from pages that were loaded before the service worker activated are not intercepted.
Can a service worker read the request body?
Yes, event.request.clone().text() or .json() will read the body. Reading the body consumes the stream, so clone the request first if you need to forward the original.
What happens to cached content when the user clears their browser data? All Cache API storage is cleared along with cookies and other site data when the user clears browser data. Design for this — don’t use the cache as the only persistence layer for user-generated content.