Chrome Service Workers: Building a Cache-First Offline Layer That Actually Works
How Chrome Service Workers enable offline-capable apps via the Cache API: a worked cache-first setup, lifecycle pitfalls, and DevTools verification steps.
24 Oct 2025, 09:46 UTC

The useful answer first
If you want a Chrome web app to load instantly on repeat visits and keep working when the network drops, a Service Worker with a cache-first strategy is the supported way to do it. The Service Worker registers as a programmable proxy between your page and the network, caches your static assets during its install event, and answers matching requests from the Cache Storage API without touching the network at all. The catch: everything hinges on getting the lifecycle and versioning right, or you will ship stale files with no obvious way to recover.
How the mechanism works
A Service Worker is a JavaScript file that runs in its own thread, separate from your page. Chrome routes every network request from controlled pages through it, firing a fetch event you can intercept. Its lifecycle has three phases:
- Registration — your page calls
navigator.serviceWorker.register(). Chrome downloads the worker script. - Installation — the
installevent fires once per new worker version. This is where you pre-cache static assets. If anything here rejects, the worker never activates. - Activation — the
activateevent fires after old workers release control. This is the correct place to delete outdated caches.
Because Service Workers can rewrite every response your page sees, Chrome only allows them on HTTPS origins (or localhost for development). There is no opt-out; this prevents a man-in-the-middle from injecting a malicious proxy.
A worked cache-first configuration
Registration, run in your page's main script (no special permissions needed beyond serving over HTTPS):
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js')
.then(reg => console.log('SW registered, scope:', reg.scope))
.catch(err => console.error('SW registration failed:', err));
});
}The worker itself, in /sw.js at your origin root (its location determines which pages it can control):
const CACHE_NAME = 'app-static-v3';
const ASSETS = ['/', '/index.html', '/app.css', '/app.js', '/logo.svg'];
self.addEventListener('install', event => {
event.waitUntil(
caches.open(CACHE_NAME).then(cache => cache.addAll(ASSETS))
);
});
self.addEventListener('activate', event => {
event.waitUntil(
caches.keys().then(keys =>
Promise.all(keys
.filter(key => key !== CACHE_NAME)
.map(key => caches.delete(key)))
)
);
});
self.addEventListener('fetch', event => {
if (event.request.method !== 'GET') return;
event.respondWith(
caches.match(event.request).then(cached =>
cached || fetch(event.request))
);
});The fetch handler is the routing decision point: caches.match() checks Cache Storage first, and only falls back to fetch() (the real network) on a miss. The activate handler compares every existing cache name against CACHE_NAME and deletes the rest — this is your cleanup mechanism. Bumping v3 to v4 on the next deploy forces a fresh install and purges the old cache automatically.
Verifying it in Chrome DevTools
Do not trust the code; check the behavior:
- Open DevTools → Application tab → Service Workers. Confirm your worker shows as "activated and running." If it is stuck on "waiting to activate," an older worker still controls an open tab.
- Check Application → Cache Storage for
app-static-v3and confirm your assets are listed. - Reload, then look at the Network tab. Cached requests should show "(ServiceWorker)" in the Size column instead of a byte count.
- Tick the Offline checkbox in the Network tab and reload. The page should still render from cache. This is the real acceptance test.
Limits and common mistakes
A failed install blocks everything. cache.addAll() is all-or-nothing: if any single URL 404s, the promise rejects, installation fails, and the worker silently never activates. Cache only assets you control, or use individual cache.add() calls with error handling for optional files.
Stale content is the default failure mode. Cache-first means the network is never consulted for a hit. If you deploy new app.js without bumping CACHE_NAME, users keep the old file indefinitely. Pair cache-first with versioned filenames (e.g., app.a1b2c3.js) or a strict cache-name bump on every release.
Don't cache-first HTML that changes often. The pattern above suits static assets. For API responses or frequently updated pages, a network-first or stale-while-revalidate strategy is safer — cache-first trades freshness for speed, and that trade is wrong for dynamic data.
Scope surprises. A worker at /sw.js controls the whole origin; one at /app/sw.js only controls /app/*. Requests outside the scope bypass the worker entirely.
Push is a separate concern. The Push API lets the worker wake for server-sent notifications even with no tab open, but it requires its own permission prompt and subscription flow — don't assume it comes free with caching.
If you need to undo it
Registration is persistent state, so removing a broken worker is a real rollback step: call registration.unregister() from the page (or click "Unregister" in DevTools → Application → Service Workers), then delete the caches via caches.keys().then(keys => keys.forEach(caches.delete)) in the console. Note that an unregistered worker keeps controlling already-open tabs until they close.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.