Precache Manifest vs Runtime Caching for Hashed Assets

When every filename in dist/ already carries a content hash, the question is no longer how the service worker knows a file changed — the URL answers that — but when the bytes should be pulled into Cache Storage: eagerly, in one atomic block during install, or lazily, the first time a request for them happens to pass through the fetch handler.

The two modes are not competing implementations of the same idea. Precaching buys a guarantee (this exact set of files is present, offline, right now) and pays for it in install-time bandwidth and storage. Runtime caching buys cheapness and pays for it in coverage you cannot predict. Fingerprinted filenames change the arithmetic in favour of runtime caching more often than most PWA guidance admits, because the classic reason to precache — knowing whether a stable URL still holds the same bytes — has already been solved by the content hash in the filename.

What Each Mode Actually Does

Precaching takes a build-time list of { url, revision } pairs and calls cache.addAll() on it inside install. The call is atomic: if a single URL returns anything other than a cacheable response, the promise rejects, the install fails, and the previous worker stays in control. Nothing is served from the new generation until the whole set is present. The list is fixed at build time and cannot adapt to what the user actually visits.

Runtime caching registers a route — a URL pattern plus a strategy — and populates the cache as a side effect of real traffic. install does no network work at all, or almost none. Coverage grows with usage: a user who never opens the settings page never downloads the settings chunk. The cache reflects observed behaviour rather than a build-time guess.

The practical consequence shows up in the install payload. A code-split application emits one entry chunk plus dozens of route chunks; precaching the glob pulls all of them, while runtime caching pulls the entry chunk and whatever the first navigation needed.

Install-time download by strategy Three horizontal bars compare bytes fetched during the service worker install event: precaching every built file, precaching only the app shell, and relying entirely on runtime caching. Install-time download by strategy What the browser fetches during the install event Precache everything 1.9 MiB / 214 App shell only 620 KiB / 18 Runtime only 34 KiB / 2 Measured on a 214-chunk Vite 5 build, Brotli, compressed transfer size
Install cost scales with the size of the glob, not with what the user is about to do.

Side-by-Side Comparison

Dimension Precache manifest Runtime caching
Install cost Full glob downloaded before activation; 1–3 MiB is typical for a code-split app Effectively zero; first paint is unaffected
Offline completeness Deterministic — every listed route works offline on first launch Probabilistic — only visited routes are available
Storage footprint Fixed and predictable; one full generation per build Grows with usage, bounded by maxEntries or quota
Staleness risk Low for hashed URLs, high if HTML is in the glob Low; entries are content-addressed and never revalidated
Update latency New generation is complete the moment the worker activates Chunks arrive on demand; first visit after a deploy hits the network
Failure mode One missing URL aborts the whole install A single 404 affects one request
Manifest maintenance Regenerated and re-shipped every build Route patterns are written once
Suits Offline-first apps, kiosk and field tooling, small shells Content sites, large code-split SPAs, low-storage devices

The row that decides most real cases is failure mode. An atomic install is a genuine safety property — clients never run a half-populated generation — but it also means the precache manifest is a hard dependency on every URL in it existing at the origin at install time. If your deploy publishes documents before assets, or your CDN has not finished propagating, the install fails for every client in the window. The bigger the glob, the wider the target.

Why revision Is Dead Weight Here

A manifest entry has two fields, and for a fingerprinted build only one of them carries information.

Manifest entry anatomy Two manifest entries are compared: a fingerprinted URL whose revision is null, and an unhashed URL that stores a content digest. Three boxes below list the costs of keeping a redundant revision. Anatomy of a manifest entry Fingerprinted file url: /assets/main.a3f8c1d2.js revision: null the URL already encodes the bytes Unhashed file url: /manifest.webmanifest revision: 9c1e4d77f0b2 digest stored beside the URL Redundant digest duplicates the hash already in the filename Churn on rebuild new revision string re-fetches identical bytes Manifest bloat about 20 bytes each 4 KiB at 200 chunks
For a content-addressed URL the revision field restates information the filename already carries.

Workbox keys its precache bookkeeping on the pair, so a revision that moves for a reason unrelated to the content — a different Workbox minor version, a build that touched mtimes, a non-reproducible bundler run — marks the entry outdated and schedules a re-download of bytes the client already has. Suppressing it is one option:

// vite.config.js — VitePWA in injectManifest mode.
import { defineConfig } from 'vite';
import { VitePWA } from 'vite-plugin-pwa';

export default defineConfig({
  build: {
    rollupOptions: {
      output: {
        entryFileNames: 'assets/[name].[hash].js',
        chunkFileNames: 'assets/[name].[hash].js',
        assetFileNames: 'assets/[name].[hash].[ext]',
      },
    },
  },
  plugins: [
    VitePWA({
      strategies: 'injectManifest',
      srcDir: 'src',
      filename: 'sw-src.js',
      injectRegister: null,
      injectManifest: {
        globDirectory: 'dist',
        // Only the shell — route chunks are left to the runtime route.
        globPatterns: ['index.html', 'assets/main.*.{js,css}', 'assets/*.woff2'],
        globIgnores: ['**/*.map'],
        dontCacheBustURLsMatching: /\.[0-9a-f]{8,16}\.[a-z0-9]+$/,
        maximumFileSizeToCacheInBytes: 4 * 1024 * 1024,
      },
    }),
  ],
});

Vite emits [hash] as 8 base-64-ish characters by default rather than hex; if you standardise on 8 hex characters for consistency with a Webpack pipeline, widen the regex to [0-9a-zA-Z_-]{8,16} so dontCacheBustURLsMatching still matches. Monorepos running 12–16 character hashes are covered by the same bound.

An equally valid option is to drop the manifest for route chunks entirely and let a runtime route own them:

// src/sw-src.js — shell precached, route chunks cached on demand.
import { precacheAndRoute } from 'workbox-precaching';
import { registerRoute } from 'workbox-routing';
import { CacheFirst } from 'workbox-strategies';
import { ExpirationPlugin } from 'workbox-expiration';
import { CacheableResponsePlugin } from 'workbox-cacheable-response';

precacheAndRoute(self.__WB_MANIFEST);

registerRoute(
  ({ url, request }) =>
    url.origin === self.location.origin &&
    request.destination === 'script' &&
    /\/assets\/[^/]+\.[0-9a-zA-Z_-]{8,16}\.js$/.test(url.pathname),
  new CacheFirst({
    cacheName: 'route-chunks',
    plugins: [
      new CacheableResponsePlugin({ statuses: [200] }),
      new ExpirationPlugin({
        maxEntries: 120,
        maxAgeSeconds: 60 * 60 * 24 * 60,
        purgeOnQuotaError: true,
      }),
    ],
  })
);

maxEntries: 120 is doing real work. Because hashed URLs never collide, a runtime cache accumulates every chunk of every build the client has ever loaded — build 40’s chunks sit alongside build 41’s forever. The expiration plugin evicts in least-recently-used order, which for content-addressed URLs is exactly the right policy: the chunks nobody has requested since the last deploy are the ones from the previous deploy.

Sizing the Two Against Each Other

Install cost versus offline coverage A plot area with install-time cost on the horizontal axis and offline completeness on the vertical axis. Precache-all sits high on both, runtime-only low on both, and a shell plus runtime route sits in the middle. Install cost versus offline coverage Offline coverage Install-time cost low high Precache all every route offline Shell + runtime shell offline, rest on demand Runtime only no guaranteed shell
The middle position is not a compromise — for a fingerprinted, code-split build it is usually the correct point.

The hybrid position earns its place because the two halves have different risk profiles. The shell is small, always needed, and stable across deploys except for its own hash, so precaching it is cheap and the atomic-install guarantee is worth having. Route chunks are numerous, individually optional, and content-addressed, so runtime caching gives you the same cache hit on the second visit at a fraction of the install cost — and a missing chunk degrades one route instead of failing the install for everyone.

Storage is the constraint that eventually forces the decision on mobile. A precache of every generation would grow without bound if activate did not delete the previous one, and on a device where the origin’s quota allotment is a few tens of megabytes, a 2 MiB precache plus an unbounded runtime cache is enough to trigger QuotaExceededError mid-install. purgeOnQuotaError and a bounded maxEntries are what keep that recoverable.

Where the manifest quietly grows

Two settings decide how big the glob really is, and neither announces itself. globPatterns is matched against the built tree after the bundler has run, so adding a route adds entries without anyone editing the worker config. maximumFileSizeToCacheInBytes silently drops anything larger than its default 2 MiB — so a large vendor chunk or a hero image can be absent from the precache while the build log reports success, producing an app that is offline-complete in staging and not in production.

Print both numbers on every build. injectManifest and generateSW return count and size, and the difference between the file count on disk and the manifest count is the set of files that were skipped. A build that emits 214 assets and precaches 209 is telling you five files exceeded the size limit; whether that matters depends on which five.

Behaviour Across Consecutive Deploys

The steady-state difference only becomes visible on the second and third deploy, and it is worth walking through concretely. Take a build with one entry chunk and forty route chunks, where a typical deploy changes the entry chunk and three route chunks.

Under a full precache, the new manifest lists forty-one URLs. Thirty-seven of them are byte-identical to entries the client already holds — but they live in the previous generation’s cache, which the new install does not read from. Workbox mitigates this by copying matching url + revision pairs forward from the old precache instead of refetching, so the network cost is the four genuinely new chunks. That optimisation is exactly what a stray non-null revision defeats: change the revision string and the pair no longer matches, so all forty-one are fetched again.

Under a shell precache plus a runtime route, the install fetches one chunk. The three changed route chunks arrive when the user navigates to those routes, if they ever do. The thirty-seven unchanged chunks are still sitting in the runtime cache under their original URLs and are served from there with no network access at all — no copy-forward step, no bookkeeping, because a content-addressed URL is its own cache key.

The second arrangement also degrades better when the deploy is partially propagated. A client that installs while the CDN is still serving the previous asset set for some paths gets an install failure under the atomic manifest and a single slow request under the runtime route. Multi-region propagation delay is measured in seconds, but the install window is exactly when a freshly deployed worker is being fetched by every active client at once.

Neither arrangement removes the need to delete old generations on activate. Precaching leaks a whole generation per deploy if you skip cleanupOutdatedCaches(); runtime caching leaks individual chunks unless maxEntries bounds the cache. The leak rates differ by an order of magnitude, not in kind.

Verification

Read the actual state rather than the intent. Paste this into the DevTools console on the origin:

// Enumerate Cache Storage: entry counts and approximate bytes per cache.
const report = [];
for (const name of await caches.keys()) {
  const cache = await caches.open(name);
  const requests = await cache.keys();
  let bytes = 0;
  for (const request of requests) {
    const response = await cache.match(request);
    if (response) bytes += (await response.clone().blob()).size;
  }
  report.push({ cache: name, entries: requests.length, KiB: Math.round(bytes / 1024) });
}
console.table(report);
console.log(await navigator.storage.estimate());

Three signals to read from the output. A workbox-precache-v2-* cache holding far more entries than your shell means the glob is too wide. A runtime cache whose entry count exceeds maxEntries means the route is not attached to the expiration plugin. And a navigator.storage.estimate() usage figure climbing across deploys, rather than resetting, means old generations are not being deleted on activate.

To confirm the revision suppression worked, grep the deployed worker:

# Every hashed URL in the manifest should carry revision:null.
curl -s https://example.com/sw.js \
  | grep -o '"url":"[^"]*","revision":[^,}]*' \
  | grep -v '"revision":null' \
  | grep -E '\.[0-9a-zA-Z_-]{8,16}\.[a-z0-9]+"'
# Empty output means every fingerprinted entry has a null revision.

When to Reconsider

Precache everything when the application must work offline on first launch with no prior navigation — field data-collection tools, in-venue kiosks, anything installed once over a good connection and then used on none. The install cost is paid at a moment you control, and deterministic coverage is worth more than bandwidth.

Precache nothing when the site is content-first and the worker exists only to make repeat visits fast. A blog or documentation site with a network-first document route and a cache-first asset route needs no manifest at all; the second page view is already served from Cache Storage, and the install event stays free.

Revisit the split after every routing change. A glob written when the app had six routes will silently pull forty when it has forty, because globPatterns describes a shape, not a size. Print count and size from injectManifest in CI and fail the build when either crosses a threshold — the same class of guard rail described in the CI/CD asset pipeline guide.

One thing neither mode fixes: if the document itself is in the precache, the strategy question is irrelevant because the client is already pinned to one build’s HTML. Keep documents on a network-first route regardless of which side of this decision you land on.