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.
Precache the shell on install
Section titled “Precache the shell on install”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.
Route each fetch by request type
Section titled “Route each fetch by request type”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.
Delete old caches on activate
Section titled “Delete old caches on activate”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)), ), ), );});Queue failed writes with Background Sync
Section titled “Queue failed writes with Background Sync”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`);Reload with the network switched off
Section titled “Reload with the network switched off”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.
See also
Section titled “See also”- Choosing a caching strategy
- Service worker lifecycle: install to update
- Offline fallback for service workers
- Storage persistence, quotas, and eviction
- The Offline Cookbook (web.dev)
- Background Synchronization API (developer.mozilla.org)
- StorageManager: estimate() method (developer.mozilla.org)
← Back to the Guides overview.