Skip to content

Offline strategies

Published

At the end of this guide your app loads and stays usable with the network switched off, because one service worker answers each kind of request from the right place: the cached shell for the app itself, the network with a cached fallback for data, and a stored offline page for a navigation that misses both. There is no single “offline mode”; offline is a set of per-request decisions about when to trust the cache and when to trust the network.

You need a registered service worker (follow Getting started if you have none) and an understanding of when install and activate fire, described in Service worker lifecycle: install to update. The code below is one complete sw.js; each step adds a handler to it.

Open a versioned cache in install and addAll() the HTML, CSS, and JavaScript every view needs plus the offline page. The version in the cache name is what lets the next release replace the whole set. addAll() rejects if any single file fails to fetch, and a rejected waitUntil() promise abandons the install, so list only files you control.

const VERSION = 'v3';
const SHELL_CACHE = `shell-${VERSION}`;
const DATA_CACHE = `data-${VERSION}`;
const SHELL = ['/', '/app.css', '/app.js', '/offline.html'];
self.addEventListener('install', (event) => {
event.waitUntil(caches.open(SHELL_CACHE).then((cache) => cache.addAll(SHELL)));
});

Shell files served with hashed names can stay cache-first for as long as the cache lives; an unhashed /app.js only updates when VERSION changes and the worker reinstalls.

Decide per request. A navigation gets the network first so the user sees fresh HTML, with the cached shell when the network fails. Shell assets are cache-first: instant, updated by the next worker version. API responses are network-first with the last good copy as the fallback, which costs one full network wait per request while online. Images and fonts use stale-while-revalidate: the cached copy returns at once, and a background fetch refreshes it, so one request in a row can be stale.

self.addEventListener('fetch', (event) => {
const { request } = event;
if (request.method !== 'GET') return; // let POST and friends hit the network
const url = new URL(request.url);
if (request.mode === 'navigate') {
event.respondWith(networkFirst(request, SHELL_CACHE, '/offline.html'));
} else if (SHELL.includes(url.pathname)) {
event.respondWith(caches.match(request).then((hit) => hit || fetch(request)));
} else if (url.pathname.startsWith('/api/')) {
event.respondWith(networkFirst(request, DATA_CACHE));
} else if (request.destination === 'image' || request.destination === 'font') {
event.respondWith(staleWhileRevalidate(request, DATA_CACHE));
}
});
async function networkFirst(request, cacheName, fallbackPath) {
const cache = await caches.open(cacheName);
try {
const fresh = await fetch(request);
if (fresh.ok) cache.put(request, fresh.clone());
return fresh;
} catch {
const cached = await cache.match(request);
if (cached) return cached;
if (fallbackPath) return cache.match(fallbackPath);
throw new Error(`offline and ${request.url} is not cached`);
}
}
async function staleWhileRevalidate(request, cacheName) {
const cache = await caches.open(cacheName);
const cached = await cache.match(request);
const refresh = fetch(request).then((response) => {
if (response.ok) cache.put(request, response.clone());
return response;
});
return cached || refresh;
}

cache.put() consumes the response body, so the code clones before storing and returns the original. The strategies, their costs, and a Workbox mapping are compared in Choosing a caching strategy.

activate runs once the new worker controls the page and the old one is gone, which makes it the safe moment to delete the previous version’s caches. Caches are shared across the whole origin, so match on your own prefix rather than deleting everything. Keep this handler short: fetches queue behind a long activation.

self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((names) =>
Promise.all(
names
.filter((name) => (name.startsWith('shell-') || name.startsWith('data-')) && !name.endsWith(VERSION))
.map((name) => caches.delete(name)),
),
),
);
});

networkFirst returns stale data for a GET; a failed POST needs a retry. Where ServiceWorkerRegistration.sync exists, store the body in IndexedDB and register a sync tag; the browser fires sync when connectivity returns. The API is Chromium-only (see Background Sync for versions), so the page must also work when the registration throws.

// In the page, after the POST fails:
async function queueForSync(tag) {
const registration = await navigator.serviceWorker.ready;
if (!('sync' in registration)) return false; // show "saved locally, retry later" instead
try {
await registration.sync.register(tag);
return true;
} catch {
return false;
}
}
// In sw.js:
self.addEventListener('sync', (event) => {
if (event.tag === 'outbox') event.waitUntil(replayOutbox(event.lastChance));
});

event.lastChance is true on the final retry the browser will make, which is the moment to tell the user the write was dropped.

Check the storage budget before precaching large files

Section titled “Check the storage budget before precaching large files”

Every cache counts against the origin’s quota, and eviction can remove all of it at once. navigator.storage.estimate() returns a conservative quota and the current usage in bytes; the numbers are approximate because of compression and deduplication. Request navigator.storage.persist() for data that must survive, as described in Storage persistence, quotas, and eviction.

const { usage, quota } = await navigator.storage.estimate();
console.log(`${(usage / 1048576).toFixed(1)} MB used of ${(quota / 1048576).toFixed(0)} MB`);

Load the app once online so install runs, then in Chrome DevTools open the Network panel, select Offline from the Network throttling drop-down next to the Disable cache checkbox, and reload. The shell paints from shell-v3, /api/ requests return the last cached copy, and a navigation to an uncached URL shows /offline.html. Test the installed app from a cold start too; a warm tab already holds everything in memory.

← Back to the Guides overview.