Service Workers and Offline Caching
Service Workers let you intercept network requests and control what gets cached. With the right setup, your web app works even when the user has no internet connection at all.
This guide walks you through the complete setup: registering a Service Worker, caching assets during installation, intercepting fetch requests, and choosing the right caching strategy for different types of content.
What You Need to Know First
A Service Worker is a JavaScript file that runs separately from your main page. It cannot access the DOM, but it can intercept network requests and read from and write to the Cache API. Think of it as a programmable proxy sitting between your app and the network.
Service Workers only work over HTTPS and on localhost. If you are testing locally, most browsers give you a free pass. In production, you need a valid TLS certificate.
Step 1: Register a Service Worker
Before the browser will do anything with a Service Worker, you must register it. You do this in your main JavaScript file, usually on page load:
if ('serviceWorker' in navigator) {
window.addEventListener('load', async () => {
try {
const registration = await navigator.serviceWorker.register('/sw.js');
console.log('Service Worker registered with scope:', registration.scope);
} catch (err) {
console.error('Service Worker registration failed:', err);
}
});
}
The scope determines which pages the Service Worker controls. By default, it covers the directory where sw.js lives and all subdirectories. You can narrow it by passing a scope option:
navigator.serviceWorker.register('/sw.js', { scope: '/app/' });
Registration is asynchronous. The browser queues the registration and resolves the promise once it has successfully downloaded the Service Worker file.
Step 2: Handle the Installation Event
The browser fires an install event when it first downloads your Service Worker. This is the right place to pre-cache static assets so they are available offline immediately:
// sw.js
const CACHE_NAME = 'app-cache-v1';
const ASSETS = [
'/',
'/index.html',
'/styles.css',
'/app.js',
'/images/icon.svg'
];
self.addEventListener('install', (event) => {
console.log('Service Worker: installing');
// Keep the worker in the installing phase until this promise resolves
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => {
console.log('Caching app shell assets');
return cache.addAll(ASSETS);
})
);
// Activate immediately without waiting for existing pages to close
self.skipWaiting();
});
event.waitUntil() is critical here. If the promise you pass to it rejects, the installation fails and the browser discards the Service Worker. The skipWaiting() call tells the browser to activate as soon as installation finishes, rather than waiting until all pages under its scope close.
Step 3: Handle the Activation Event
The activate event fires after installation completes. Use it to clean up old caches left over from previous versions of your Service Worker:
const CURRENT_CACHE = 'app-cache-v1';
self.addEventListener('activate', (event) => {
console.log('Service Worker: activating');
event.waitUntil(
caches.keys().then((cacheNames) => {
return Promise.all(
cacheNames.map((cacheName) => {
if (cacheName !== CURRENT_CACHE) {
console.log('Deleting old cache:', cacheName);
return caches.delete(cacheName);
}
})
);
})
);
// Take control of all already-open pages immediately
self.clients.claim();
});
self.clients.claim() lets the activated Service Worker start controlling pages that are already open. Without it, the worker only takes control over pages opened after activation.
Step 4: Intercept Fetch Requests
The real work happens in the fetch event. Every time your page requests a resource, the Service Worker can intercept it and decide what to return:
self.addEventListener('fetch', (event) => {
// Only handle GET requests
if (event.request.method !== 'GET') return;
event.respondWith(
// Your caching strategy goes here
);
});
You must call event.respondWith() synchronously or the browser uses its default network behavior. Pass a promise that resolves to a Response object.
Step 5: Choose a Caching Strategy
Different resources call for different strategies. Here are the three most common ones.
Strategy A: Cache First, Fall Back to Network
Good for: static assets like images, CSS, and JavaScript files that do not change often.
self.addEventListener('fetch', (event) => {
if (event.request.method !== 'GET') return;
event.respondWith(
caches.match(event.request).then((cachedResponse) => {
if (cachedResponse) {
return cachedResponse; // Found in cache, return it
}
// Not in cache, go to network
return fetch(event.request).then((networkResponse) => {
// Optionally cache the response for next time
const responseClone = networkResponse.clone();
caches.open(CURRENT_CACHE).then((cache) => {
cache.put(event.request, responseClone);
});
return networkResponse;
});
})
);
});
The browser checks the cache first. If it finds a match, it returns it immediately without hitting the network. If not, it fetches from the network and caches a copy for future requests.
Strategy B: Network First, Fall Back to Cache
Good for: content that changes frequently, like API responses or user-generated content.
self.addEventListener('fetch', (event) => {
if (event.request.method !== 'GET') return;
event.respondWith(
fetch(event.request)
.then((networkResponse) => {
// Cache a copy for offline use
const responseClone = networkResponse.clone();
caches.open(CURRENT_CACHE).then((cache) => {
cache.put(event.request, responseClone);
});
return networkResponse;
})
.catch(() => {
// Network failed, try the cache
return caches.match(event.request);
})
);
});
This tries the network first and gives the user fresh content when available. Only when the network request fails does it fall back to the cache.
Strategy C: Stale While Revalidate
Good for: balancing speed with freshness. The user gets an instant response while the cache updates in the background.
self.addEventListener('fetch', (event) => {
if (event.request.method !== 'GET') return;
event.respondWith(
caches.open(CURRENT_CACHE).then((cache) => {
return cache.match(event.request).then((cachedResponse) => {
// Fetch and update cache in the background
const fetchPromise = fetch(event.request).then((networkResponse) => {
cache.put(event.request, networkResponse.clone());
return networkResponse;
});
// Return cached response if available, otherwise wait for network
return cachedResponse || fetchPromise;
});
})
);
});
The user sees a cached response immediately if one exists. In the background, the Service Worker fetches fresh content and updates the cache. On the next request, the user gets the updated version.
Step 6: Update the Cache When Your App Changes
When you deploy a new version of your app, you need to update the cache. The browser detects changes to the Service Worker file by comparing the new file byte-for-byte with the one it has stored. If they differ, the browser treats it as a new version.
The standard approach is to change the cache name whenever you deploy:
const CACHE_NAME = 'app-cache-v2'; // Increment this on each deployment
Then clean up old caches in the activation handler as shown in Step 3. Users who already have your app cached get the old version until they reload the page. To prompt them automatically, send a message from the page:
// In your main app JS
navigator.serviceWorker.ready.then((registration) => {
// Check if there is a new Service Worker waiting
if (registration.waiting) {
registration.waiting.postMessage({ type: 'SKIP_WAITING' });
}
});
// In sw.js
self.addEventListener('message', (event) => {
if (event.data.type === 'SKIP_WAITING') {
self.skipWaiting();
}
});
Step 7: Test Your Offline Setup
The easiest way to test Service Workers is in Chrome DevTools:
- Open DevTools and go to the Application tab.
- Select Service Workers on the left. You should see your registered worker listed.
- Go to the Cache Storage section. You can see all named caches and verify that your assets are cached.
- To test offline behavior, check the Offline box in the Network tab, then reload the page.
If the page loads with the network disabled, your caching is working.
You can also simulate an update by editing your sw.js file. After saving, the browser detects the change and fires a new install event. Reload the page to pick up the new version.
Common Pitfalls
The Service Worker file must be served over HTTPS. If you register a Service Worker on http://, the browser ignores it silently. Use a tool like ngrok for local HTTPS during development.
Cache everything before activation. If your install handler fails to cache all assets, the entire Service Worker is discarded and your users get no offline support. Consider caching a minimal subset during install and handling the rest lazily.
Watch out for fetch redirects. If a fetch request redirects, the response is still cacheable, but you need to handle the redirect chain correctly. Caching a 301 or 302 response is usually not what you want.
Cache size limits. Browsers impose storage limits per origin. Large caches can hit these limits and cause eviction. Cache only what you need.
See Also
- Web Workers — Running code in background threads
- Browser Storage — Comparing localStorage, IndexedDB, and Cache API
- The Notifications API — Desktop notifications from the browser