Versioning Frontend Build Artifacts with Content Hashes

You deployed, and some users are running a mix of old and new JavaScript — or a CDN is still serving last week’s bundle under this week’s filename. Content-hash naming removes the class of problem entirely: a file’s name is a statement about its bytes, so a stale name is impossible and a changed file is always a new URL.

When to use this pattern

  • You serve a bundled frontend from a CDN and want long-lived caching without stale-asset incidents.
  • Your deploys currently invalidate broadly, or bust caches with a query string.
  • You need rollback to be a matter of pointing at a previous entry document rather than re-uploading.

Prerequisites

What the hash actually buys

Two distinct properties fall out of naming a file after its content, and it is worth separating them because teams usually adopt the pattern for the first and are rescued by the second.

Fixed Filenames vs Content-Hashed Filenames Across a Deploy The upper row shows app.js kept at a fixed name: after deploy the CDN still holds the old body until an invalidation propagates, and a client may run stale code. The lower row shows hashed names: vendor and shared chunks are unchanged so they keep their cache entries, while the changed chunk arrives under a new name that nothing has cached. FIXED FILENAME /assets/app.js before deploy /assets/app.js after deploy — same URL must invalidate the CDN and hope every edge complied stale risk mixed old/new code CONTENT-HASHED FILENAMES vendor.a91f3c.js shared.77c3de.js app.4f9c1b.js unchanged — still cached unchanged — still cached new name — new URL no invalidation needed stale is not representable Only the changed chunk is re-downloaded — typically 8–15% of total bundle bytes on a routine release. The entry document stays unhashed; it is the one file that must be revalidated on every load.

The first property is correctness: a URL never changes meaning, so a cache — browser, CDN, or corporate proxy you have never heard of — cannot serve the wrong bytes. The second is efficiency: because unchanged chunks keep their names, a returning visitor downloads only what actually changed, which on a typical release is a small fraction of the bundle.

Complete working example

// vite.config.ts — content-hashed output, deterministic chunking.
import { defineConfig } from 'vite';

export default defineConfig({
  build: {
    // Emit a manifest so the deploy can enumerate what this build produced.
    manifest: true,
    rollupOptions: {
      output: {
        // [hash] is computed from the chunk's own content, after minification.
        entryFileNames:  'assets/[name].[hash].js',
        chunkFileNames:  'assets/[name].[hash].js',
        assetFileNames:  'assets/[name].[hash][extname]',
        // Keep third-party code in its own chunk: it changes far less often than app code,
        // so it keeps its hash — and its cache entry — across most releases.
        manualChunks: (id) => (id.includes('node_modules') ? 'vendor' : undefined),
      },
    },
  },
  define: {
    // Never embed a build timestamp: it changes the content, so it changes every hash.
    __BUILD_TIME__: JSON.stringify('static'),
  },
});
#!/usr/bin/env bash
# scripts/deploy-assets.sh — upload additively, switch the entry document last.
set -euo pipefail
BUCKET="s3://shop-assets"

# 1. Hashed assets first, cached for a year and marked immutable. Safe because the
#    name changes whenever the bytes do, so this entry can never become wrong.
aws s3 sync ./dist/assets "${BUCKET}/assets" \
  --cache-control 'public, max-age=31536000, immutable' \
  --exclude 'index.html'          # no --delete: previous releases stay reachable

# 2. The entry document last, revalidated on every request so it can point at new hashes.
aws s3 cp ./dist/index.html "${BUCKET}/index.html" \
  --cache-control 'no-cache'      # "revalidate", not "never cache"

# 3. Invalidate only the entry document — the hashed assets never need it.
aws cloudfront create-invalidation --distribution-id E123EXAMPLE --paths '/index.html'

Step-by-step walkthrough

Ordering is the whole deploy. Assets go up before the document that references them. Reverse the order and there is a window — seconds, but real — in which the new HTML asks for chunks that do not exist yet, and every visitor in that window gets a blank page and a chunk-load error.

Why Assets Must Be Uploaded Before the Entry Document The upper sequence uploads hashed assets, then the entry document, then invalidates only that document — no window of inconsistency. The lower sequence uploads the entry document first, creating a window in which visitors receive HTML referencing chunks that have not been uploaded yet, producing chunk-load errors. CORRECT ORDER 1 · sync hashed assets 2 · copy index.html 3 · invalidate /index.html no bad window REVERSED ORDER 1 · copy index.html window: HTML references missing chunks every visitor in it gets a chunk-load error 2 · sync hashed assets The window is short — seconds to a minute — which is exactly long enough to be intermittent and hard to reproduce.

immutable is a promise you can only keep with hashes. It tells the browser not to revalidate at all for the lifetime of the entry. With a fixed filename that promise is a lie you will eventually have to break with a cache purge; with a content hash it is simply true.

no-cache on the entry document does not mean “do not cache”. It means “cache, but revalidate before use”, so a returning visitor gets a conditional request and a cheap 304 when nothing changed. no-store — which genuinely disables caching — is the wrong tool and costs a full round trip on every navigation.

Vendor splitting is a caching decision, not an organisational one. Third-party dependencies change on upgrade cycles measured in weeks; your application code changes several times a day. Separating them means the largest chunk keeps its hash across most releases.

Never invalidate /*. A wildcard invalidation throws away every cached asset including the ones that did not change, which converts a deploy into a cold-cache event for every visitor. Invalidating the single entry document is sufficient and costs nothing.

Verification

# 1. Hashes are stable across two builds of the same commit.
npm run build && sha256sum dist/assets/*.js | sort > /tmp/a.txt
rm -rf dist && npm run build && sha256sum dist/assets/*.js | sort > /tmp/b.txt
diff /tmp/a.txt /tmp/b.txt && echo "deterministic"     # any diff means an unstable input

# 2. Headers are correct per class, live.
curl -sI https://shop.example.com/assets/vendor.a91f3c.js | grep -i cache-control
# → cache-control: public, max-age=31536000, immutable
curl -sI https://shop.example.com/ | grep -i cache-control
# → cache-control: no-cache

# 3. The previous release's chunks are still reachable (mid-session users depend on this).
curl -s -o /dev/null -w '%{http_code}\n' https://shop.example.com/assets/app.PREVIOUS.js   # → 200

The first check is the one that matters most. If two builds of the same commit produce different hashes, every deploy invalidates every cache and the entire scheme reduces to expensive churn.

Bytes Re-Downloaded by a Returning Visitor per Deploy Three horizontal bars over a 1.4 megabyte bundle. Fixed filenames with a wildcard invalidation re-download the full 1,400 kilobytes. Hashed names with nondeterministic builds also re-download 1,400 kilobytes because every hash changed. Hashed names with deterministic builds re-download only the 180-kilobyte application chunk. fixed names + /* purge 1,400 KB hashed, unstable hashes 1,400 KB hashed, deterministic 180 KB — the changed chunk only 0 700 KB 1,400 KB Hashing without determinism buys nothing: the middle bar is the common failure and it looks like success.

Common pitfalls

Hashes that change every build. The usual causes are an embedded build time, module identifiers derived from absolute paths, or a source-map comment containing the working directory. Run the determinism check above in CI so the regression is caught on the pull request that introduces it.

Deleting the previous release’s assets. aws s3 sync --delete removes chunks that open sessions are still requesting, producing chunk-load errors for anyone who had the site open. Keep at least two releases and expire older ones on a schedule, as covered in setting artifact retention policies.

Hashing the entry document too. If index.html is hashed, nothing can find it. It is the one file whose name must be stable and whose cache lifetime must be short — that asymmetry is what makes the rest of the scheme work.

Splitting chunks too finely. It is tempting to give every route its own chunk so that a change touches as little as possible, but each chunk is a request, and beyond roughly twenty the connection overhead outweighs the cache-hit gain on a mobile connection. Split along boundaries that genuinely change at different rates — vendor code, shared UI, per-route code — rather than mechanically per file.

Relying on the hash for rollback and nothing else. Content hashes make the previous release’s assets reachable, which is necessary but not sufficient: something still has to know which entry document was live before. Record the deployed document alongside the commit, so a rollback is a lookup rather than an archaeology exercise.

← Back to Artifact Management Strategies for Frontend Builds