Precaching vs Runtime Caching: What Belongs in the Install Manifest?
This comparison sits under Service Worker Caching Strategies inside Advanced Caching Strategies & CDN Architecture. Precaching downloads a fixed manifest of files during service-worker install, so those files are guaranteed present afterwards — including offline. Runtime caching stores responses as they are requested, so nothing is downloaded speculatively but nothing is guaranteed either.
Teams routinely precache the entire build, turning a first visit into a multi-megabyte background download that competes with the page the user is actually reading.
Rapid Diagnosis: What Is Your Install Costing?
- Measure the install payload. In Application → Service Workers, trigger an install and watch the Network panel. Anything above a few hundred kilobytes deserves scrutiny.
- Check when it runs. A precache that starts during initial page load competes for bandwidth with the LCP resource. It should be deferred until the page is idle.
- Count files versus files used. If the manifest has 300 entries and a typical session touches 20, you are paying for 280.
- Look at storage pressure.
navigator.storage.estimate()tells you how much you have taken; eviction under pressure removes your whole origin's storage, not just the excess.
The Comparison
| Axis | Precaching | Runtime caching |
|---|---|---|
| Install cost | Full manifest, up front | Zero |
| Offline guarantee | Yes, for listed files | Only for what was visited |
| Update mechanism | Revision hashes in the manifest | Per-strategy expiry and revalidation |
| Storage growth | Bounded and known | Unbounded without an expiration plugin |
| Best for | App shell, offline fallback, critical routes | Images, API responses, rarely visited pages |
| Failure mode | Bloated install, wasted bytes | Cold cache when it matters |
Root Cause Analysis: How Both Go Wrong
1. Precaching a glob of the whole build. A default globPatterns that matches every emitted asset includes route chunks nobody visits and images for pages that will never be opened.
2. Precaching content-hashed assets that the HTTP cache already handles. Immutable assets served with a year-long Cache-Control are already fast on repeat visits — see setting up immutable cache headers. Precaching them buys offline availability only, so it is worth it only if offline is a requirement.
3. Runtime caching with no expiration. A CacheFirst image strategy without a maximum entry count grows until the browser evicts the entire origin's storage — including the precache you depended on.
4. Precaching HTML with the wrong strategy. A precached HTML document is served from the old service worker until the new one activates, which is exactly the stale-page-after-deploy problem covered in fixing stale HTML after a deploy.
Step-by-Step Resolution
1. Precache only the guaranteed set. The app shell, the offline fallback page, and the assets those two need — nothing else.
import { precacheAndRoute } from 'workbox-precaching';
// Injected by the build, but SCOPED: shell and fallback only.
precacheAndRoute([
{ url: '/offline.html', revision: '8f2c1a' },
{ url: '/app-shell.html', revision: '8f2c1a' },
...self.__WB_MANIFEST.filter((e) => /\/(app|shell)\.[a-f0-9]+\.(js|css)$/.test(e.url))
]);
// trade-off: a narrow precache means a user who goes offline on an unvisited
// route gets the fallback page rather than the real one. That is the correct
// default — offering every route offline costs every user the download.
Expected outcome: install payload drops from megabytes to tens of kilobytes.
2. Runtime-cache everything else with explicit limits.
import { registerRoute } from 'workbox-routing';
import { CacheFirst, StaleWhileRevalidate } from 'workbox-strategies';
import { ExpirationPlugin } from 'workbox-expiration';
registerRoute(({ request }) => request.destination === 'image',
new CacheFirst({
cacheName: 'images',
plugins: [new ExpirationPlugin({ maxEntries: 60, maxAgeSeconds: 30 * 86400,
purgeOnQuotaError: true })]
}));
registerRoute(({ url }) => url.pathname.startsWith('/api/'),
new StaleWhileRevalidate({ cacheName: 'api-v1' }));
// trade-off: purgeOnQuotaError lets the browser drop this cache under pressure
// instead of evicting everything. It means images can vanish silently — which
// is right for images and wrong for anything the app depends on.
Expected outcome: caches stay bounded and eviction, when it happens, removes the least important data first.
3. Defer the install work until the page is idle. A precache that competes with the first paint costs LCP for a benefit that only materialises on the next visit.
// In the page, not the worker: register after load, when the thread is free.
addEventListener('load', () => {
if (!('serviceWorker' in navigator)) return;
const start = () => navigator.serviceWorker.register('/sw.js');
'requestIdleCallback' in window ? requestIdleCallback(start, { timeout: 4000 })
: setTimeout(start, 2000);
});
// trade-off: deferring means a very short first visit may end before the worker
// installs, so the second visit pays the install instead. That is strictly
// better than slowing the first one.
4. Version the runtime caches deliberately. Include a version in each cacheName and delete old ones on activate, so a schema change cannot leave incompatible responses behind.
Verification
Confirm the install cost first: clear storage, load the page, and check total bytes transferred during install in the Network panel. Then check the guarantee you paid for — go offline and load the shell and the fallback page.
Watch storage over a realistic session:
const { usage, quota } = await navigator.storage.estimate();
console.log(`${(usage / 1048576).toFixed(1)}MB of ${(quota / 1048576).toFixed(0)}MB`);
for (const name of await caches.keys()) {
console.log(name, (await (await caches.open(name)).keys()).length, 'entries');
}
// Expect bounded counts per cache. A count that keeps climbing across sessions
// means an expiration plugin is missing on that route.
Finally, verify the update path: deploy a change, reload twice, and confirm the new assets are served. A precache that never updates is worse than no precache, because it pins users to an old build.
FAQ
Should I precache my JavaScript bundles?
Only the shell's. Content-hashed bundles are already served from the HTTP cache with a long TTL on repeat visits, so precaching adds offline availability and nothing else. If offline use of a route is not a product requirement, runtime-cache those chunks on first use instead and keep the install cheap.
How large is too large for a precache manifest?
Judge it by download time on a slow connection rather than by a byte count. If the install takes more than a few seconds on a throttled mobile profile, it is competing with real page activity and should be trimmed. In practice, most sites should be well under a megabyte; anything approaching several megabytes almost always includes route chunks that could be runtime-cached.
What happens when storage quota is exceeded?
The browser evicts storage for the entire origin, not just the cache that overflowed — including IndexedDB and your precache. That is why purgeOnQuotaError matters: it marks a specific cache as safe to drop first. Track usage against quota and treat sustained growth as a bug, because the failure mode is losing everything at once, usually on the devices with the least space.
Does a service worker help performance at all if I have good HTTP caching?
For repeat visits on a warm cache, barely — the HTTP cache already serves those bytes without a network request. The service worker earns its place in three other situations: genuine offline support, control over stale responses so you can serve instantly and revalidate behind the request, and resilience when the network is present but unusable. If none of those is a requirement, correct Cache-Control headers deliver most of the benefit with none of the update complexity.
How should I handle API responses in a service worker?
Cache them under a versioned name with a strategy that matches the data's tolerance for staleness — revalidate-on-use for feeds and listings, network-first for anything transactional, and no caching at all for authenticated or personalised responses unless you can key them per user. The mistake to avoid is a single blanket strategy for /api/, which inevitably serves one endpoint's stale data where it is not acceptable.
What happens to the caches when a user clears site data?
Everything goes: the precache, the runtime caches, IndexedDB and the registration itself. The next visit behaves exactly like a first visit, including the install cost — which is another reason to keep that cost small. Design for this rather than against it: a site whose first visit is fast does not need the cache to be fast, while one that depends on a warm cache degrades badly for anyone who clears storage or arrives in a private window.
Related
- Service Worker Caching Strategies — the strategy vocabulary these decisions draw on.
- Debugging Service Worker cache misses in production — when the cache exists but is not being used.
- Fixing stale HTML after a deploy — the update path for precached documents.