# Offline strategies

> Make a PWA load and stay usable with no network: precache the shell, route each fetch by request type, clean up old caches, and serve an offline fallback page.

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](/guides/getting-started/)
if you have none) and an understanding of when `install` and `activate` fire, described in
[Service worker lifecycle: install to update](/reference/service-worker/lifecycle/). The
code below is one complete `sw.js`; each step adds a handler to it.

## 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.

```js
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

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.

```js
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](/guides/offline/caching-strategies/).

## 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.

```js
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

`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](/compatibility/background-sync/) for versions), so the page must also
work when the registration throws.

```js
// 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

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](/reference/storage/persistence/).

```js
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

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.

:::observed
With **Offline** selected in Chrome DevTools, a warning icon appears next to the **Network**
tab, and each request the service worker does not answer from a cache is logged in the
Console as `Failed to load resource: net::ERR_INTERNET_DISCONNECTED`.
:::

## See also

- [Choosing a caching strategy](/guides/offline/caching-strategies/)
- [Service worker lifecycle: install to update](/reference/service-worker/lifecycle/)
- [Offline fallback for service workers](/reference/service-worker/offline-fallback/)
- [Storage persistence, quotas, and eviction](/reference/storage/persistence/)
- [The Offline Cookbook](https://web.dev/articles/offline-cookbook) (web.dev)
- [Background Synchronization API](https://developer.mozilla.org/en-US/docs/Web/API/Background_Synchronization_API) (developer.mozilla.org)
- [StorageManager: estimate() method](https://developer.mozilla.org/en-US/docs/Web/API/StorageManager/estimate) (developer.mozilla.org)

← Back to the [Guides](/guides/) overview.