You open a web app on a spotty train Wi-Fi connection. The page loads instantly. Forms submit. Data persists. No spinner, no "you are offline" banner. That's not magic — that's a Service Worker.
I've used Service Workers in production apps, and they're one of the most powerful APIs in the browser — but also one of the most misunderstood. Let me walk you through how they actually work, from lifecycle to caching strategies to debugging.
What Is a Service Worker?
A Service Worker is a JavaScript file that runs in a separate thread from your main page. It sits between your app and the network, acting as a programmable proxy. It can intercept network requests, serve responses from cache, handle push notifications, and run background sync — all without a page open.
Key things to know:
- No DOM access — it runs in a worker context, not a window context
- Event-driven — it wakes up on events (fetch, push, sync) and goes dormant otherwise
- HTTPS only — Service Workers require a secure origin (localhost works for dev)
- Asynchronous — all APIs are promise-based
The Service Worker Lifecycle
A Service Worker goes through three main phases: install, activate, and fetch. Understanding this lifecycle is critical because it affects how and when your caching logic runs.
Registration
Before anything happens, you register the Service Worker from your page:
// main.js
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js')
.then(reg => {
console.log('Service Worker registered:', reg.scope);
})
.catch(err => {
console.error('Registration failed:', err);
});
}The scope determines which pages the Service Worker controls. By default, it's the directory containing the SW file. Place it at the root (/sw.js) to control your entire site.
Install Event
The install event fires once when a new Service Worker is detected (either first time or when the file changes). This is where you precache essential assets — your app shell, critical CSS, fonts, and offline fallback page.
// sw.js
const CACHE_NAME = 'app-v1';
const PRECACHE_ASSETS = [
'/',
'/index.html',
'/styles.css',
'/app.js',
'/offline.html',
];
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME)
.then(cache => cache.addAll(PRECACHE_ASSETS))
.then(() => self.skipWaiting())
);
});event.waitUntil() keeps the SW alive until the promise resolves. skipWaiting() tells the browser to activate this new SW immediately instead of waiting for existing tabs to close. Use it cautiously — if your new SW has breaking changes, old tabs might get confused.
Activate Event
The activate event fires after installation, once the SW takes control. This is the right place to clean up old caches:
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then(cacheNames => {
return Promise.all(
cacheNames
.filter(name => name !== CACHE_NAME)
.map(name => caches.delete(name))
);
}).then(() => self.clients.claim())
);
});clients.claim() makes the SW take control of all open pages immediately (without requiring a reload). Without it, pages loaded before the SW activated won't be controlled by it.
Fetch Event
The fetch event fires on every network request the controlled page makes. This is where you implement your caching strategy:
self.addEventListener('fetch', (event) => {
event.respondWith(
caches.match(event.request)
.then(cachedResponse => {
if (cachedResponse) {
return cachedResponse;
}
return fetch(event.request).then(networkResponse => {
// Cache new requests for next time
if (networkResponse.ok) {
const responseClone = networkResponse.clone();
caches.open(CACHE_NAME).then(cache => {
cache.put(event.request, responseClone);
});
}
return networkResponse;
});
})
);
});This is a basic cache-first strategy — check the cache, fall back to network, and cache new responses. Let's look at the common strategies in detail.
Caching Strategies
Picking the right strategy depends on what you're caching. Here are the three most useful ones.
Cache-First
Best for static assets that rarely change — fonts, images, CSS, JS bundles.
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;
});
})
);
});The user gets instant responses from cache. The network is only hit on a cache miss. Trade-off: stale content until the cache is updated (on next SW update).
Network-First
Best for API calls and frequently updated content — user data, news feeds, dashboards.
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))
);
});Try the network first. If it fails (offline, timeout), serve from cache. This gives you fresh content when online and graceful degradation offline.
Stale-While-Revalidate
Best for non-critical content that should feel fast but stay reasonably fresh — avatars, article bodies, config files.
self.addEventListener('fetch', (event) => {
event.respondWith(
caches.open(CACHE_NAME).then(cache => {
return cache.match(event.request).then(cached => {
const fetchPromise = fetch(event.request).then(response => {
cache.put(event.request, response.clone());
return response;
});
return cached || fetchPromise;
});
})
);
});Serve from cache immediately (instant response), then fetch from network in the background to update the cache for next time. The user sees potentially stale content on the current visit but gets fresh content on the next visit.
Which Strategy to Use When
| Resource Type | Strategy | Why |
|---|---|---|
| HTML (app shell) | Cache-first | Your shell is versioned with the SW |
| CSS/JS bundles | Cache-first | Fingerprinted URLs never change |
| API data (user profile) | Network-first | Needs to be fresh |
| Images, fonts | Cache-first or stale-while-revalidate | Large, rarely change |
| News feed, lists | Network-first | Must be current |
Building an Offline-Capable App
Let's put it all together. Here's a real-world Service Worker that uses different strategies for different resource types:
// sw.js
const STATIC_CACHE = 'static-v2';
const DYNAMIC_CACHE = 'dynamic-v2';
const STATIC_ASSETS = [
'/',
'/index.html',
'/styles.css',
'/app.js',
'/offline.html',
];
// Install: precache app shell
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(STATIC_CACHE)
.then(cache => cache.addAll(STATIC_ASSETS))
.then(() => self.skipWaiting())
);
});
// Activate: clean old caches
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then(keys =>
Promise.all(
keys
.filter(key => key !== STATIC_CACHE && key !== DYNAMIC_CACHE)
.map(key => caches.delete(key))
)
).then(() => self.clients.claim())
);
});
// Fetch: route-based strategy
self.addEventListener('fetch', (event) => {
const { request } = event;
const url = new URL(request.url);
// Skip non-GET requests
if (request.method !== 'GET') return;
// API calls → network-first
if (url.pathname.startsWith('/api/')) {
event.respondWith(networkFirst(request));
return;
}
// Static assets → cache-first
if (isStaticAsset(url.pathname)) {
event.respondWith(cacheFirst(request));
return;
}
// Everything else → stale-while-revalidate
event.respondWith(staleWhileRevalidate(request));
});
function cacheFirst(request) {
return caches.match(request).then(cached =>
cached || fetch(request).then(response => {
const clone = response.clone();
caches.open(STATIC_CACHE).then(cache => cache.put(request, clone));
return response;
})
);
}
function networkFirst(request) {
return fetch(request)
.then(response => {
const clone = response.clone();
caches.open(DYNAMIC_CACHE).then(cache => cache.put(request, clone));
return response;
})
.catch(() => caches.match(request).then(cached =>
cached || caches.match('/offline.html')
));
}
function staleWhileRevalidate(request) {
return caches.open(DYNAMIC_CACHE).then(cache =>
cache.match(request).then(cached => {
const fetchPromise = fetch(request).then(response => {
cache.put(request, response.clone());
return response;
});
return cached || fetchPromise;
})
);
}
function isStaticAsset(pathname) {
return /\.(js|css|png|jpg|jpeg|svg|gif|woff2?)$/.test(pathname);
}And register it from your HTML:
<script>
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js');
});
}
</script>Wait for window.load so the SW registration doesn't compete with critical resource loading.
Debugging Service Workers
Service Workers are notoriously tricky to debug because they run in a separate thread and have a complex lifecycle. Here's what works.
Chrome DevTools
Open DevTools → Application tab → Service Workers panel. You'll see:
- Status: running / stopped / waiting to activate
- Update on reload: checkbox to force SW update on every page reload (essential during development)
- Bypass for network: skip the SW entirely
- Unregister: remove the SW completely
- Inspect: opens a dedicated DevTools window for the SW thread (set breakpoints, see console logs)
The Cache Storage panel (also under Application) shows all cached responses — click to inspect headers and bodies.
Common Gotchas
-
The old SW is still running. Browsers keep the old SW alive until all tabs are closed. Use "Update on reload" in DevTools, or call
skipWaiting()+clients.claim(). -
CORS issues with opaque responses. Cross-origin requests (fonts from CDNs, images from other domains) return opaque responses that can't be inspected. You can cache them, but you can't read their content. Cache them with care — opaque responses count against storage quotas without letting you verify them.
-
The SW didn't take control. After registration, the SW doesn't control the current page until it's activated and the page reloads. Use
clients.claim()or reload. -
Caching the offline page fails silently. If any URL in your
PRECACHE_ASSETSlist returns a 404,cache.addAll()rejects and the entire install fails. Test your asset list. -
Cache versioning. When you update your SW, bump the cache name (
app-v1→app-v2) so the activate handler cleans up old caches. If you don't, users get stuck with stale assets.
Programmatic Cache Inspection
// Check what's in the cache (run in page console)
caches.keys().then(names => console.log('Caches:', names));
caches.open('app-v1').then(cache =>
cache.keys().then(keys => keys.forEach(k => console.log(k.url)))
);Quick Reference
Here's a mental model for working with Service Workers:
- Install = precache your app shell (the HTML/CSS/JS that loads first)
- Activate = clean up old caches from previous versions
- Fetch = intercept requests and apply the right caching strategy
- Static assets → cache-first (fast, rarely change)
- API calls → network-first (freshness matters)
- General content → stale-while-revalidate (fast + eventual freshness)
Service Workers are the foundation of Progressive Web Apps. Once you understand the lifecycle and caching strategies, you can make any web app work offline — not just PWAs with a manifest. Start small: cache your app shell, add an offline fallback page, and iterate from there.