Home / Blog / Service Worker caching: shipping an offline-first web app

Service Worker caching: shipping an offline-first web app

Building a web app that keeps working when the network dies is a modern web superpower. Cache strategies, precaching, runtime cache, update flow.

A Service Worker lets a web developer write middleware between the browser and the network. Used well: web apps that work offline, instant re-visit, background sync.

I’ve set up Service Worker caching on two PWA projects. One was a news site (offline reading), the other a productivity app (instant re-open). The patterns are different.

What a Service Worker is

A script that runs in the browser on a thread separate from your main JS. It can intercept network requests, read and write caches, receive push notifications, and handle background sync.

It’s the backbone of a Progressive Web App (PWA).

Registration

if ('serviceWorker' in navigator) {
    window.addEventListener('load', () => {
        navigator.serviceWorker.register('/sw.js').then(reg => {
            console.log('SW registered:', reg);
        });
    });
}

HTTPS is required (localhost is exempt). Put /sw.js at the root so its scope is /.

Cache strategy taxonomy

Five core patterns:

1. Cache-First: check the cache first, fall back to network. For static assets (CSS, JS, fonts, images).

self.addEventListener('fetch', (event) => {
    event.respondWith(
        caches.match(event.request).then(response => {
            return response || fetch(event.request);
        })
    );
});

2. Network-First: network first, fall back to cache. For dynamic content (API responses, HTML).

self.addEventListener('fetch', (event) => {
    event.respondWith(
        fetch(event.request).catch(() => caches.match(event.request))
    );
});

3. Stale-While-Revalidate: serve from cache immediately, refresh from network in the background. Instant response, eventually fresh.

self.addEventListener('fetch', (event) => {
    event.respondWith(
        caches.open('dynamic').then(cache => {
            return cache.match(event.request).then(cachedResponse => {
                const networkFetch = fetch(event.request).then(response => {
                    cache.put(event.request, response.clone());
                    return response;
                });
                return cachedResponse || networkFetch;
            });
        })
    );
});

4. Cache-Only: only the cache. Precached static assets, fallback pages.

5. Network-Only: only the network. Analytics, POST requests, authentication.

Pick a strategy per URL pattern.

Workbox: the library that makes this bearable

Google’s Workbox reduces these patterns to one-liners:

import { registerRoute } from 'workbox-routing';
import { CacheFirst, NetworkFirst, StaleWhileRevalidate } from 'workbox-strategies';

registerRoute(
    ({ request }) => request.destination === 'image',
    new CacheFirst({ cacheName: 'images' })
);

registerRoute(
    ({ url }) => url.pathname.startsWith('/api/'),
    new NetworkFirst({ cacheName: 'api' })
);

registerRoute(
    ({ request }) => request.mode === 'navigate',
    new StaleWhileRevalidate({ cacheName: 'pages' })
);

Declarative, easy to maintain, battle-tested.

Precaching on install

Load critical assets into the cache during the install event. Every visit after the first is offline-capable.

const PRECACHE_VERSION = 'v3';
const PRECACHE_ASSETS = [
    '/',
    '/offline.html',
    '/styles/main.css',
    '/scripts/app.js',
    '/fonts/inter-var.woff2'
];

self.addEventListener('install', (event) => {
    event.waitUntil(
        caches.open(PRECACHE_VERSION).then(cache => {
            return cache.addAll(PRECACHE_ASSETS);
        })
    );
});

Bump the version on every deploy and new assets land in the cache.

Cache cleanup: old versions

When a new SW activates, wipe the old caches:

self.addEventListener('activate', (event) => {
    event.waitUntil(
        caches.keys().then(keys => {
            return Promise.all(
                keys
                    .filter(key => key !== PRECACHE_VERSION)
                    .map(key => caches.delete(key))
            );
        })
    );
});

Otherwise the cache grows on every deploy, quota fills up, and the browser evicts without warning.

Offline fallback page

When both network and cache fail, show the user a fallback:

self.addEventListener('fetch', (event) => {
    if (event.request.mode === 'navigate') {
        event.respondWith(
            fetch(event.request).catch(() => caches.match('/offline.html'))
        );
    }
});

A “You are offline” page beats a blank one.

Update flow

What happens to active users when you deploy a new SW?

Default behaviour:
1. New SW downloads in the background
2. Install event
3. Old SW stays active, new SW is “waiting”
4. All tabs close and reopen: new SW activates

In practice users don’t close tabs and never see the new version. Solutions:

A. Refresh prompt. When the new SW is waiting, show a banner “New version available, refresh?”.

B. skipWaiting + clients.claim. The new SW takes over as soon as it installs. Risk: mid-session version mismatch (users see content from different versions).

C. Version check. In main JS, check navigator.serviceWorker.controller.state; on mismatch, reload.

I prefer the refresh prompt. The user sees clearly that there’s a new version.

Runtime cache management

The runtime cache has to stay bounded. It can’t grow forever.

In Workbox:

registerRoute(
    ({ request }) => request.destination === 'image',
    new CacheFirst({
        cacheName: 'images',
        plugins: [
            new ExpirationPlugin({
                maxEntries: 100,
                maxAgeSeconds: 30 * 24 * 60 * 60  // 30 days
            })
        ]
    })
);

More than 100 images triggers eviction of the oldest. Anything older than 30 days is auto-deleted.

Quota management

The browser gives each origin a storage quota (typically 60% of device storage, dynamic). When it’s close to full:

navigator.storage.estimate().then(estimate => {
    const percentUsed = (estimate.usage / estimate.quota) * 100;
    if (percentUsed > 80) {
        // Clean up old caches
    }
});

Persistent storage request:

navigator.storage.persist().then(granted => {
    if (granted) console.log('Storage will not be cleared');
});

If the user explicitly consents, the browser promises not to evict the quota.

Background Sync

Automatically finish work the user started while offline once they’re back online:

// Main JS
navigator.serviceWorker.ready.then(registration => {
    registration.sync.register('send-messages');
});

// SW
self.addEventListener('sync', (event) => {
    if (event.tag === 'send-messages') {
        event.waitUntil(sendPendingMessages());
    }
});

async function sendPendingMessages() {
    const db = await openIndexedDB();
    const pending = await db.getAll('pending_messages');
    for (const msg of pending) {
        await fetch('/api/messages', { method: 'POST', body: JSON.stringify(msg) });
        await db.delete('pending_messages', msg.id);
    }
}

The user wrote a message on the subway offline; it goes out automatically when they reconnect.

Periodic Sync

Periodic background sync: regular sync without user interaction. A news app fetching articles in the background, for instance.

API is new and restricted (user opt-in, browser approval). For now it’s progressive enhancement.

Offline indicator

Show online/offline state to the user:

window.addEventListener('online', () => showBanner('You are back online'));
window.addEventListener('offline', () => showBanner('You are offline, some features are limited'));

Banner or status indicator. The user understands what’s happening.

Debug discipline

SW debugging is painful. The Chrome DevTools Application panel SW tab is essential:

  • Update on reload: every refresh gets a fresh SW
  • Bypass for network: disable the SW
  • Unregister: remove the SW

Without these tools, users end up stuck in stale cache.

Real numbers

On the news site SW caching delivered:
– Repeat visit load time: 2.4s to 200ms (-92%)
– Offline read capability: 0% to the last 20 articles
– Mobile data usage: -45% (high cache hit rate)

On the productivity app:
– Instant open from home screen
– Offline edit plus sync
– 60% of users used the app offline at least once a month

Common mistakes

1. Over-caching. Caching everything eats storage. Cache only the assets that matter.

2. Stale data served forever. Cache-first without a version bump means the user sees three-month-old content. Versioning and TTLs are required.

3. Caching POST requests. POSTs aren’t cached by default but custom logic goes wrong. Stick to caching GETs.

4. Caching authenticated responses. User A’s authenticated response gets cached and served to User B. Don’t cache authenticated responses, or use user-scoped cache keys.

5. Underestimating debug time. SW bugs hide well, regressions are hard to notice. Test thoroughly, roll out incrementally.

Closing thought

A Service Worker starts simple and gets sophisticated fast. Basic precaching plus network-first covers most sites. Offline-first PWAs combine runtime cache plus background sync plus IndexedDB.

Workbox absorbs most of the complexity. On new projects I reach for Workbox and don’t hand-write SWs anymore.

The real power of a Service Worker is re-engagement. Instant second visit, offline work, push notifications, together they make a PWA feel like a native app.

Have a project on this topic?

Leave a brief summary — I’ll get back to you within 24 hours.

Get in touch