Skip to content

Performance · Concept

Precaching strategies

Published

Precaching stores a fixed, build-time list of files in Cache Storage during the service worker’s install event, before any page requests them, so the app shell is on the device from the first visit onwards and loads offline. It is the counterpart of runtime caching, which stores responses as they are requested, and the two are used together: precache the shell, runtime-cache the content.

The install handler calls cache.addAll() with the list; the promise rejects if any entry fails, and the worker does not install, which keeps a half-filled shell from going live. Each later deploy ships a new list. The hard part is change detection: a file at an unchanged URL (/index.html) looks identical to the cache even when its contents differ, so every entry needs either a content hash in its URL (/app.3f2a1c.js), which makes a changed file a new URL, or a separate revision string that the worker compares on install (workbox-precaching, developer.chrome.com). Maintaining revisions by hand is the error-prone step, which is why a build plugin generates the manifest.

The shell: the HTML that renders the frame, its CSS and JavaScript chunks, the icons and the offline page. Content that varies per user or per request, large media, and anything the user may never open belong to runtime caching under a strategy (caching strategies overview, developer.chrome.com). A precache that grows into megabytes slows every install and, on Chrome, delays the first visit’s own requests while addAll() competes for bandwidth; the shell of a typical app is a few hundred kilobytes.

The install-time fetch() for each entry goes through the HTTP cache. A file served with a long max-age at an unchanged URL can therefore land in the precache as a stale copy even though the revision changed; the server-side rule is long max-age only for hashed URLs and no-cache (revalidate) for unhashed ones such as the HTML and the service worker script itself (Service worker caching and HTTP caching, web.dev). Workbox requests unhashed entries with cache: 'reload' semantics by appending the revision as a __WB_REVISION__ query parameter, which bypasses a stale HTTP cache entry without changing the URL the page requests.

Precaching uses only caches.open(), cache.addAll(), and the install and activate events, present in Chrome 40, Firefox 44, Safari 11.1, and every current engine (BCD api.CacheStorage, api.ServiceWorkerGlobalScope). Safari’s seven-day eviction of script-writable storage removes the precache for sites the user has not interacted with, after which the next visit reinstalls it; see eviction and best-effort storage.

The Workbox form is what most projects ship; the hand-written form shows what it does.

Workbox precacheAndRoute with a generated manifest

Section titled “Workbox precacheAndRoute with a generated manifest”

The build tool replaces self.__WB_MANIFEST with the { url, revision } list; revision is null for hashed URLs and a content hash otherwise. precacheAndRoute() installs, serves, and cleans up.

import { precacheAndRoute, cleanupOutdatedCaches } from 'workbox-precaching';
cleanupOutdatedCaches();
precacheAndRoute(self.__WB_MANIFEST);
// Generated at build time, for example:
// [{ url: '/index.html', revision: '383676' }, { url: '/app.3f2a1c.js', revision: null }]

cleanupOutdatedCaches() removes precaches left by older Workbox versions; the current precache’s own stale entries are removed during activate without it.

Without Workbox the same mechanism is a map of URL to revision and an install handler that re-fetches only changed entries, which keeps the second install from re-downloading the whole shell.

const PRECACHE = 'precache-v2';
const MANIFEST = { '/index.html': 'a1b2c3', '/app.3f2a1c.js': null, '/offline.html': 'd4e5f6' };
self.addEventListener('install', (event) => {
event.waitUntil((async () => {
const cache = await caches.open(PRECACHE);
for (const [url, revision] of Object.entries(MANIFEST)) {
const key = revision ? `${url}?__rev=${revision}` : url;
if (!(await cache.match(key))) {
await cache.put(key, await fetch(key, { cache: 'reload' }));
}
}
})());
});

The fetch handler looks up ${url}?__rev=${revision} for a requested url; the { cache: 'reload' } option forces a network fetch past the HTTP cache for the changed entry.

Detecting a usable Cache Storage before relying on the precache

Section titled “Detecting a usable Cache Storage before relying on the precache”

A page can tell whether the shell is precached and otherwise keep its network-first behaviour, for instance to decide whether to show an “Available offline” indicator.

async function shellIsPrecached() {
if (!('caches' in window) || !('serviceWorker' in navigator)) {
return false; // no Cache Storage: nothing is precached
}
const names = await caches.keys();
const precache = names.find((n) => n.startsWith('workbox-precache') || n.startsWith('precache-'));
if (!precache) return false;
const cache = await caches.open(precache);
return Boolean(await cache.match('/index.html', { ignoreSearch: true }));
}

ignoreSearch: true matters because the stored key carries the revision query parameter; without it the lookup for /index.html misses.

Specifications

SpecificationStatus
None.