Static Asset Fingerprinting Fundamentals

Static asset fingerprinting enforces deterministic cache lifecycles by embedding cryptographic hashes directly into filenames. This technique eliminates version conflicts, guarantees automatic cache invalidation on content changes, and removes the need for CDN-wide purges when new builds ship.

Frontend engineers and DevOps teams deploy this strategy to align immutable caching directives with edge network behaviour. Correct implementation reduces origin load, accelerates time-to-first-byte across all geographic regions, and prevents stale asset delivery during rolling releases and blue-green deployments.

What Fingerprinting Solves

Traditional deployments overwrite files in place: main.js at v1.2 becomes main.js at v1.3. CDNs and browsers cached the original response. Without an explicit purge or a no-cache directive forcing revalidation, visitors receive the old file — sometimes for days — until TTL expiry. The application breaks when old JavaScript tries to import new CSS class names, when old HTML references new API endpoints, or when stale service workers serve a mixed-version bundle.

These failures are particularly damaging in single-page applications where JavaScript modules import each other dynamically. If app.js is cached from a previous build but router.js has been updated to export a renamed function, the import fails silently or throws at runtime. Users see a blank screen or a JavaScript error console, and the failure is invisible to your monitoring because the CDN returned a 200 — just for stale content.

Fingerprinting breaks this pattern. The SHA-256 content digest of every source byte is truncated to 8 hex characters and embedded in the filename: main.a1b2c3d4.js. When any byte changes, the digest changes, and the new URL is treated as a completely different resource. Old and new assets coexist on the CDN simultaneously, so users mid-session on the old version continue to get consistent responses while new visitors receive the updated build. The only document that needs purging is the HTML entry point, because it holds the references to all hashed filenames.

This separation between versioned HTML and immutable assets is the central architectural insight. Understanding content hashing versus semantic versioning helps teams decide when to embed hashes in filenames versus when a manual ?v=1.3 query parameter is sufficient — and the answer is almost always filename hashing for production traffic.

Anatomy of a Fingerprinted URL

Every fingerprinted URL is four independent decisions glued together, and each one is owned by a different part of the stack. The path prefix is chosen by the deployment layer and is what your CDN cache rule matches on. The logical name is chosen by the bundler entry configuration and is the key your manifest and your server-rendered templates look up. The digest segment is computed from the emitted bytes. The extension drives MIME typing and compression negotiation at the edge.

Fingerprinted URL anatomy The URL /assets/main.a1b2c3d4.js split into four labelled segments: the path prefix that CDN rules match, the logical name used as a manifest key, the eight hex character content digest, and the file extension that drives MIME typing. Anatomy of a fingerprinted asset URL /assets/ main .a1b2c3d4 .js path prefix CDN cache rule matches here logical name manifest key stable per entry content digest 8 hex of SHA-256 changes with bytes extension MIME type and compression rules Raise the digest to 12-16 hex where a monorepo emits thousands of chunks to one prefix.
Four independent segments, four different owners: deployment layer, bundler entry config, hasher, and content negotiation.

Keeping these four concerns separate is what makes the rest of the stack simple. A CDN rule can match ^/assets/ without knowing anything about your bundler. A server-side template can look up main without knowing the current digest. And a rollback can swap the digest without touching the prefix, the name, or the extension. The naming conventions that keep those segments machine-parseable are catalogued in the static asset naming conventions reference.

When Fingerprinting Is Not Enough

Fingerprinting guarantees that a changed file gets a new URL. It does not guarantee that the file was built correctly, that the manifest was updated atomically with the asset upload, or that the CDN will serve the right Cache-Control headers. Each of those requires explicit configuration. A fingerprinted build with Cache-Control: no-cache on the assets directory is functionally equivalent to no fingerprinting at all — the browser revalidates every asset on every page load, negating the performance benefit while adding URL complexity. The sections below cover each requirement in sequence.

The Fingerprint Lifecycle

The following diagram traces an asset from source file to cached browser response. Each stage has a distinct role: build tools compute hashes and write the manifest, the deploy step propagates files atomically, the CDN edge stores the immutable response, and the browser uses the manifest-resolved URL to retrieve the correct version.

Fingerprint lifecycle Six stages laid out as a snake flow: build compiles and splits chunks, the hasher renames files with a truncated SHA-256, a manifest records the mapping, deploy syncs assets before HTML, the CDN edge caches immutably, and the browser serves from disk cache without revalidating. From source byte to disk cache 1 - Build compile, minify, split chunks emitted 2 - Hash and rename SHA-256 over output bytes truncate to 8 hex 3 - Manifest logical name to URL consumed by templates 4 - Deploy assets sync first HTML written last 5 - Edge cache max-age=31536000 immutable, HIT forever 6 - Browser disk cache, no revalidate SRI verifies payload Only HTML entry points are purged hashed assets are immutable by construction
The fingerprint lifecycle: source bytes are hashed and renamed at build time, recorded in a manifest, deployed atomically, cached immutably at the CDN edge, and retrieved by browsers.

What This Area Covers

Fingerprinting is one idea with eight distinct engineering surfaces, and a production incident usually starts on exactly one of them. The tour below is the fastest route from a symptom to the reference that fixes it.

Naming, keys, and headers

The URL is the cache key, so URL design is cache design. The cache key architecture reference explains how CDNs assemble a key from path, query, and selected headers, and what to normalise away. Two decisions branch off it: whether to version by query parameter or by filename, and how to roll back a cache key after a bad deploy without stranding in-flight sessions. A third failure mode is easy to miss until hit ratio collapses: a stray Vary response header multiplies every hashed object into per-variant copies, which is the subject of the Vary header pitfalls guide.

Once the key is right, the headers decide how long the answer lives. Start at fingerprinting in HTTP headers for the full response-header contract, then narrow to ETag versus immutable Cache-Control when you are deciding whether validators still earn their round trip. When a response looks correct but is provably stale, the Age and Date header debugging guide shows how to read the two headers together to work out exactly which cache tier is holding the object.

Choosing and computing the digest

Content hashing versus semantic versioning frames the top-level choice; how to choose between a content hash and a version hash covers the hybrid case where a release identifier still has to appear somewhere. At scale the two follow-on questions are collision headroom, addressed in preventing hash collisions in large frontend projects, and churn control — hashing vendor bundles versus app bundles explains why the two deserve different chunking and different expectations about how often their URLs move. When a release has to be undone, rolling back a content-hashed release is the procedure.

On the algorithm itself, MD5 versus SHA-256 for assets covers compatibility and cost, safely truncating content hash length covers how short is short enough, and xxHash and non-cryptographic hashes in bundlers covers the case where build speed matters more than tamper resistance because integrity is enforced separately.

Making the hash reproducible

A digest is only useful if the same input produces it twice. Deterministic build outputs is the umbrella reference, and why deterministic builds matter for asset fingerprinting states the contract you are actually relying on. In practice, most breakage comes from three sources: environment drift, covered in pinning lockfiles and Node versions for stable hashes; plugin-injected entropy, covered in debugging phantom hash changes in CI; and debug tooling, covered in source map hashes and debug builds, where the sourceMappingURL comment quietly becomes part of the hashed payload.

Publishing the map

Renaming files is only half the job — something has to tell the runtime what the new names are. Asset manifest generation is the reference for producing that map. Its three companions cover the format question, Webpack Manifest Plugin versus assets-manifest.json; the consumption question, consuming an asset manifest in server-rendered templates; and the safety question, validating asset manifests in CI.

Everything that is not JavaScript or CSS

Bundlers hash their own graph well and hash the rest of your repository badly. Hashing images, fonts, and media covers the assets that arrive through other paths. Three of them have their own traps: fingerprinting responsive image srcsets, where one logical image becomes six URLs; hashing web fonts without breaking @font-face src, where the reference lives inside a stylesheet that is itself being hashed; and cache-busting video and audio assets, where range requests and byte-serving change the calculus.

Proving the bytes

Finally, Subresource Integrity validation turns the filename hash into an enforced guarantee. Generating SRI hashes in your build pipeline is the implementation, debugging SRI validation failures is the incident playbook, and SRI with crossorigin and CDN CORS headers covers the cross-origin configuration that silently disables integrity checking when it is wrong.

Algorithm Selection

The hashing algorithm determines both collision safety and toolchain compatibility. Use the table below to select an algorithm and truncation length for your project.

Algorithm Collision Probability Output Length Truncation Default Production Use
MD5 Non-trivial at scale 32 hex 8 Avoid — cryptographically broken
SHA-1 Theoretical weaknesses 40 hex 8 Deprecated — strip from CI
SHA-256 Negligible (2¹²⁸ address space) 64 hex 8 Recommended
xxHash64 Negligible for naming 16 hex 8 Fine when SRI enforces integrity
BLAKE3 Negligible 64 hex 8 Viable, less toolchain support

Production systems default to SHA-256. Truncating to 8 hex characters yields 4,294,967,296 unique combinations, which comfortably exceeds the asset count of enterprise-scale applications. Increase to 12–16 characters for monorepos with thousands of chunks where birthday-paradox collision probability becomes measurable. The trade-off analysis for truncation lengths is covered in detail in the safely truncating content hash length guide.

The MD5 vs SHA-256 for assets reference covers legacy compatibility scenarios, including reverse proxies that cannot handle 64-character filename segments and corporate firewalls that reject requests with unusual URL patterns.

Digest and length decision tree Two stacked decisions. The first splits the algorithm choice into SHA-256 truncated to eight hex for production defaults, xxHash64 for build speed, and MD4 or MD5 as a legacy path to migrate away from. The second splits truncation length by chunk count into eight, twelve, and sixteen hex characters. Choosing a digest and a truncation length Which digest algorithm? for production output SHA-256 to 8 hex default for production collision-safe margin xxHash64 to 8 hex fastest bundler hash pair it with SRI MD4 or MD5 legacy Webpack 4 default migrate off in CI How many chunks? in one CDN prefix under 500 to 8 hex single app bundle 500-5000 to 12 hex large monorepo over 5000 to 16 hex shared asset origin
Algorithm first, length second: the two decisions are independent, and only the second one scales with chunk count.

Build Tool Integration

Bundlers must compute hashes during compilation, rename output files, and update all internal references automatically. Manual post-build renaming breaks module resolution, causes hydration mismatches in server-rendered frameworks, and invalidates source maps. Each major build tool exposes a token-based filename template for this purpose. The per-tool configuration surface is catalogued in the build tool and framework asset pipeline reference.

Webpack 5 Production Configuration

const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');

module.exports = {
  mode: 'production',
  entry: {
    app: './src/index.js',
  },
  output: {
    path: path.resolve(__dirname, 'dist'),
    filename: '[name].[contenthash:8].js',
    chunkFilename: 'chunks/[name].[contenthash:8].js',
    assetModuleFilename: 'assets/[hash:8][ext][query]',
    clean: true,
  },
  optimization: {
    moduleIds: 'deterministic',
    chunkIds: 'deterministic',
    runtimeChunk: 'single',
    splitChunks: {
      cacheGroups: {
        vendor: {
          test: /[\\/]node_modules[\\/]/,
          name: 'vendors',
          chunks: 'all',
        },
      },
    },
  },
  plugins: [
    new HtmlWebpackPlugin({ template: './src/index.html' }),
    new MiniCssExtractPlugin({ filename: 'css/[name].[contenthash:8].css' }),
  ],
};

The contenthash token hashes only the content of that chunk, not the entire dependency graph. This means a change to Button.js does not rehash vendor.js, preserving long-lived cache entries for unchanged third-party code. The deterministic flags for moduleIds and chunkIds ensure module-to-integer assignments stay stable across builds when unrelated files are added or removed — without these, module IDs shift numerically and cause phantom hash changes across the whole bundle. The runtimeChunk: 'single' extraction pulls the chunk-loading table out of every entry; how that runtime chunk interacts with vendor invalidation is detailed in the runtimeChunk and vendor hash invalidation guide.

The webpack output hashing setup guide covers the full configuration surface including source maps, asset modules, and the common issue of missing asset hashes in Webpack 5.

Vite 5 Deterministic Asset Output

import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [react()],
  build: {
    assetsDir: 'assets',
    sourcemap: false,
    rollupOptions: {
      output: {
        assetFileNames: 'assets/[name]-[hash:8][extname]',
        chunkFileNames: 'chunks/[name]-[hash:8].js',
        entryFileNames: 'entries/[name]-[hash:8].js',
        manualChunks: {
          vendor: ['react', 'react-dom'],
        },
      },
    },
  },
});

Vite derives its [hash] from Rollup’s file-naming pipeline. The hash is content-based: only bytes that enter the chunk affect the digest, so separating vendor code into a manualChunks entry ensures React’s bundle hash changes only when React itself updates. The full configuration reference is in the Vite asset pipeline configuration guide, and the specific problem of CSS bundles moving on unrelated edits is covered in Vite CSS code-split hash churn.

Rollup 4 Standalone Configuration

export default {
  input: 'src/index.js',
  output: {
    dir: 'dist',
    format: 'es',
    assetFileNames: 'assets/[name]-[hash:8][extname]',
    chunkFileNames: 'chunks/[name]-[hash:8].js',
    entryFileNames: '[name]-[hash:8].js',
    sourcemap: false,
  },
};

Rollup 4 includes content hashing natively for ES module output without plugins. The [hash] placeholder is computed across the entire chunk dependency subgraph, meaning a shared utility module change propagates hash changes up to all importing chunks. This behaviour differs from Webpack’s contenthash — the Rollup asset optimization guide explains the distinction when migrating between tools, and manualChunks and stable chunk hashes shows how to pin the boundaries so the propagation stops where you want it to.

esbuild, Next.js, and Astro

esbuild uses --entry-names=[dir]/[name]-[hash] and --chunk-names=chunks/[name]-[hash] for content hashing. Unlike Webpack and Rollup, esbuild computes hashes based on the full dependency tree of each entry point, not just the file’s own bytes. This produces more conservative cache invalidation — a change to a shared utility will rehash every entry that imports it — but eliminates the need for a runtime chunk manifest to reassemble split modules. The esbuild fingerprinting plugins guide covers the plugin surface, and turning the esbuild metafile into an asset manifest covers the output side.

Next.js hashes automatically under _next/static/ and additionally keys some paths on a per-build identifier; the consequences of that identifier changing are covered in Next.js buildId and static chunk invalidation. Astro handles fingerprinting automatically for assets imported through its module graph. Static files in the public/ directory are not fingerprinted; use src/assets/ instead and import them in components so Astro processes them through its content pipeline. Configuration details are in the Astro static asset optimization and fingerprinting guide, and the client-directive case is covered in Astro islands and client-directive asset hashes.

The Manifest: Mapping Logical Names to Hashed URLs

Renaming a file solves cache invalidation and creates a lookup problem. Nothing in your codebase can hard-code main.a1b2c3d4.js, because that string changes on every meaningful commit. The manifest is the indirection layer: a small JSON document, emitted by the build, that maps every stable logical name to the URL that build produced.

{
  "src/main.js": "/assets/main.a1b2c3d4.js",
  "src/admin.js": "/assets/admin.b2c3d4e5.js",
  "src/styles.css": "/assets/styles.c3d4e5f6.css",
  "src/logo.svg": "/assets/logo.d4e5f6a7.svg"
}

Three consumers read this file, and they fail differently. Server-side templates read it at request time (or at boot, cached in memory) to emit <script src> values; a stale in-memory copy after a deploy produces 404s for chunks that were never uploaded. Deployment scripts read it to assert that every referenced file actually landed in object storage before HTML is switched over. Integrity tooling reads it to attach an integrity value to each entry. Because all three depend on the manifest being written atomically with the assets it describes, the manifest is the single most valuable thing to gate on in CI — the asset manifest generation reference covers emitting it from each bundler, and validating asset manifests in CI covers the assertions worth running before a release proceeds.

Two format details matter in practice. First, keys should be source-relative paths rather than output basenames, so that renaming an output template does not break every template lookup. Second, values should be root-relative rather than absolute, with the CDN origin prepended at render time — that keeps one build artifact promotable across staging and production without a rebuild. The trade-offs between the two common on-disk shapes are compared in Webpack Manifest Plugin versus assets-manifest.json.

Hashing Images, Fonts, and Media

JavaScript and CSS are the easy case, because the bundler owns their entire graph. Everything else — raster images, SVG sprites, woff2 faces, video posters, audio tracks, PDFs — reaches the browser through paths the bundler may never see. An image referenced from a CMS field, a font referenced from a hand-written stylesheet, or a video dropped into a static directory all bypass content hashing entirely and end up served under a mutable URL with whatever TTL your default rule applies.

The failure is asymmetric and slow. Unlike a stale chunk, a stale image does not throw; it simply shows the wrong logo for a year. The fix is to route every static file through a hashing step, whether that is the bundler’s asset module pipeline, a build-time copy task that renames on digest, or an image service that puts the digest in the path. The hashing images, fonts, and media reference covers each route.

# Rename any static file on its own digest, preserving the extension
for f in static/img/*.png static/img/*.svg; do
  h=$(sha256sum "$f" | cut -c1-8)
  base=$(basename "$f")
  name="${base%.*}"
  ext="${base##*.}"
  cp "$f" "dist/assets/${name}.${h}.${ext}"
  echo "${f} -> /assets/${name}.${h}.${ext}"
done

Two categories need extra care. Responsive images multiply one logical asset into a whole srcset of widths and formats, each needing its own digest and each needing to stay consistent within a single render — the mechanics are in fingerprinting responsive image srcsets. Web fonts are referenced from inside a stylesheet that is itself being hashed, so the rewrite has to happen before the CSS digest is computed, which is the subject of hashing web fonts without breaking @font-face src. Long-form media adds range requests and partial responses to the mix; see cache-busting video and audio assets.

Build Pipeline and CI Integration

Ensuring identical hashes across every CI runner requires locking three variables: the Node version, dependency resolution, and plugin configuration. One non-deterministic input anywhere in the graph — a timestamp embedded by a plugin, a randomly assigned module ID, an OS-dependent path separator, a locale-dependent sort order — invalidates the entire reproducibility guarantee. The mechanics of pinning the toolchain itself are in pinning lockfiles and Node versions for stable hashes.

The practical test for determinism is straightforward: run the build twice in the same CI job and compare the output listings with diff <(ls -1 dist/assets/ | sort) /tmp/first-run.txt. If the filenames differ between two runs on identical source code, something in the pipeline is introducing entropy. Build twice and diff is cheaper to run than bisecting a live incident.

Enforcing deterministic build outputs means treating the build environment as a sealed input. The debugging phantom hash changes in CI guide provides a systematic methodology for tracking down non-deterministic plugins, unstable sort orders in dependency resolution, and environment-specific path mutations.

GitHub Actions with S3 and CloudFront

name: Deploy Fingerprinted Assets
on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest
    env:
      BUCKET_NAME: ${{ secrets.S3_BUCKET }}
      CF_DIST_ID: ${{ secrets.CF_DISTRIBUTION_ID }}
      AWS_DEFAULT_REGION: us-east-1
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: '22'
          cache: 'npm'

      - name: Install dependencies
        run: npm ci --prefer-offline

      - name: Build with content hashes
        run: npm run build

      - name: Verify manifest exists
        run: |
          test -f dist/manifest.json || (echo "manifest.json missing — build failed" && exit 1)
          cat dist/manifest.json

      - name: Upload hashed assets (immutable)
        run: |
          aws s3 sync dist/assets/ s3://$BUCKET_NAME/assets/ \
            --cache-control "public, max-age=31536000, immutable" \
            --metadata-directive REPLACE

      - name: Upload HTML entry points (short TTL)
        run: |
          aws s3 sync dist/ s3://$BUCKET_NAME/ \
            --exclude "assets/*" \
            --cache-control "public, max-age=60, stale-while-revalidate=3600" \
            --metadata-directive REPLACE

      - name: Invalidate HTML on CloudFront
        run: |
          aws cloudfront create-invalidation \
            --distribution-id $CF_DIST_ID \
            --paths "/index.html" "/app.html" "/*.html"

This workflow uses two aws s3 sync commands deliberately: hashed assets in dist/assets/ receive max-age=31536000, immutable; HTML files at the document root receive a 60-second TTL with stale-while-revalidate. HTML is invalidated on CloudFront only, not the assets directory. This keeps asset cache hit ratios near 100% across CDN edge nodes while ensuring browsers pick up the new document within seconds of a release. Note the deliberate absence of --delete on the asset sync: leaving previous generations in place is what makes a rollback a template change rather than a redeploy.

The CI/CD asset pipeline integration guide extends this pattern to multi-stage pipelines, artifact reuse across jobs, and strategies for promoting a build artifact from staging to production without rebuilding.

CDN and Edge Cache Behaviour

Cache-Control Header Strategy

Fingerprinted assets and HTML entry points require different caching strategies. Applying immutable to HTML causes users to miss deployments; applying short TTLs to hashed assets defeats the purpose of fingerprinting.

Resource Type Cache-Control Directive Rationale
Hashed JS/CSS/fonts/images public, max-age=31536000, immutable Content will never change at this URL
HTML entry points public, max-age=60, stale-while-revalidate=3600 New builds must propagate within minutes
Service worker JS no-cache SW must always be revalidated
Unhashed static files public, max-age=300 Mutable URL — keep the stale window bounded

The immutable directive signals to supporting browsers (Chrome, Firefox, Safari) that the cached response will never change even if the user manually reloads the page. This eliminates conditional GET requests that would otherwise reach the origin with If-None-Match or If-Modified-Since headers. CDN edge nodes honour the same directive.

Setting fingerprinting in HTTP headers correctly covers the ETag header interaction, the behaviour of the immutable flag across CDN vendors, and how to configure Nginx for immutable asset serving. The ETag vs immutable Cache-Control for assets guide explains when ETags add redundant round trips and when they remain necessary for legacy clients. One header deserves separate attention: any Vary value other than Accept-Encoding on a hashed path fragments the edge cache into per-variant copies, which the Vary header pitfalls reference covers with real hit-ratio numbers.

Release Timeline at the Edge

The order in which artifacts reach the edge is the whole safety argument. Assets go first and are additive — new digests never overwrite old ones — so at every instant the origin holds a superset of what any live HTML references. Only after that does the HTML change, and only then is a purge issued.

Release timeline across three lanes A three-lane timeline. The origin lane shows build output then assets synced with HTML written last. The CDN edge lane shows the previous build still cached, then HTML purged with assets untouched. The browser lane shows the new HTML fetched while previously cached chunks are reused. Release timeline across origin, edge, and browser t0 build t1 assets t2 HTML t3 purge Origin CDN edge Browser build output hashed filenames assets synced HTML written last previous build still cached HTML purged assets untouched new HTML old chunks reused Assets are on the edge before any HTML references them, so the purge window is safe.
Assets are additive and go first; HTML is the only mutable pointer, and the only thing purged.

Cloudflare Cache Rules

For Cloudflare deployments, create a Cache Rule scoped to your assets path rather than relying on default caching:

Rule: Cache assets for one year
When: URI path matches regex ^/assets/.*\.[0-9a-f]{8,}\.(js|css|woff2|png|svg)$
Then: Cache Level = Cache Everything
      Edge Cache TTL = 1 year
      Browser Cache TTL = 1 year

Apply this via the Cloudflare dashboard or Terraform. Avoid the Bypass Cache setting on the origin Cache-Control header pass-through, which can cause the 31536000 value from S3 to be silently overridden. Full CDN cache rule configuration and purge workflows are covered in the CDN purge strategies reference, including tag-based purge for Cloudflare and Cloudflare cache-by-URL versus cache-by-tag tradeoffs.

Nginx Configuration for Immutable Assets

server {
    listen 443 ssl http2;
    server_name example.com;

    root /var/www/app/current;
    index index.html;

    # Immutable hashed assets — match exactly 8+ hex chars before the extension
    location ~* ^/assets/.*\.[0-9a-f]{8,}\.(js|css|woff2|woff|ttf|png|jpg|svg|ico)$ {
        add_header Cache-Control "public, max-age=31536000, immutable";
        add_header X-Asset-Fingerprinted "true";
        try_files $uri =404;
        access_log off;
    }

    # HTML entry points — short TTL
    location ~* \.html$ {
        add_header Cache-Control "public, max-age=60, stale-while-revalidate=3600";
        try_files $uri /index.html;
    }

    # Service worker
    location = /sw.js {
        add_header Cache-Control "no-cache";
        try_files $uri =404;
    }
}

Verification Workflow

Post-deployment verification confirms that edge nodes store the correct response and return the expected headers. Run these checks as part of your deploy job or as a smoke test triggered after propagation.

Inspect Response Headers Directly

# Check the asset — expect immutable header
curl -sI https://cdn.example.com/assets/main.a1b2c3d4.js \
  | grep -E "cache-control|x-cache|cf-cache-status|age|date"

# Force an origin fetch to bypass CDN
curl -sI https://cdn.example.com/assets/main.a1b2c3d4.js \
  -H "Cache-Control: no-cache" \
  | grep -i "cache-control"

# Confirm HTML has short TTL
curl -sI https://example.com/index.html \
  | grep "cache-control"

Expected output for the asset:

cache-control: public, max-age=31536000, immutable
cf-cache-status: HIT
age: 3600

Read age against date rather than in isolation. An age that climbs steadily is a healthy long-lived edge object; an age that resets on every request means the edge is not retaining the object at all, and the Age and Date header debugging guide walks through separating a browser-cache hit from an edge hit from an origin hit using only those two values.

Verify Manifest Completeness

# All entry points must appear in manifest.json
node -e "
const manifest = require('./dist/manifest.json');
const entries = ['src/index.js', 'src/admin.js'];
const missing = entries.filter(e => !manifest[e]);
if (missing.length) { console.error('Missing:', missing); process.exit(1); }
console.log('Manifest OK:', Object.keys(manifest).length, 'entries');
"

SHA-256 Cross-Check

# Confirm the deployed file matches the local build output
LOCAL_HASH=$(sha256sum dist/assets/main.a1b2c3d4.js | awk '{print $1}')
REMOTE_HASH=$(curl -sf https://cdn.example.com/assets/main.a1b2c3d4.js | sha256sum | awk '{print $1}')
if [ "$LOCAL_HASH" != "$REMOTE_HASH" ]; then
  echo "HASH MISMATCH: local=$LOCAL_HASH remote=$REMOTE_HASH"
  exit 1
fi
echo "Hash verified: $LOCAL_HASH"

Subresource Integrity

Subresource Integrity (SRI) adds a second layer of tamper protection. The build pipeline computes a base64-encoded SHA-384 digest of each output file and embeds it in the integrity attribute of every <script> and <link> tag. Browsers block execution if the fetched payload does not match the declared hash — protecting users from CDN-level content injection and supply chain attacks.

# Generate SRI hash for a built asset
openssl dgst -sha384 -binary dist/assets/main.a1b2c3d4.js \
  | openssl base64 -A

Inject the result into your HTML template:

<script
  src="/assets/main.a1b2c3d4.js"
  integrity="sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/uxy9rx7HNQlGYl1kPzQho1wx4JwY8wC"
  crossorigin="anonymous"
></script>

<link
  rel="stylesheet"
  href="/assets/styles.c3d4e5f6.css"
  integrity="sha384-Li9vy3DqF8tnTXuiaAJuML3ky+er10rcgNR/VqsVpcw+ThHmYcwiB1pbOxEbzJr7"
  crossorigin="anonymous"
/>

The crossorigin="anonymous" attribute is required when serving assets from a different origin such as a CDN subdomain, and it only works if the CDN returns a matching Access-Control-Allow-Origin header. Get one of the two wrong and the browser either blocks the resource outright or, worse, loads it with the integrity check quietly skipped. The exact header and attribute combinations are enumerated in SRI with crossorigin and CDN CORS headers.

Build-time SRI generation and the debugging workflow for ERR_SRI_SIGNATURE_MISMATCH errors are covered in the generating SRI hashes in your build pipeline guide and the debugging SRI validation failures guide.

Cache Key Architecture

CDN cache keys map request attributes to stored responses. A poorly designed cache key fragments the cache pool: two requests for main.a1b2c3d4.js that differ only in a Cookie header end up as separate cache entries, halving the effective hit ratio. A well-designed key normalises irrelevant dimensions out of the key.

The cache key architecture guide covers normalisation rules for query parameters, headers, and geographic variants. The implementing cache keys with query parameters vs filenames guide provides the decision matrix for when query strings cause CDN fragmentation and when they are safe.

Fingerprinted filenames make cache key design simpler: because the hash is in the path, the CDN never needs to key on headers or query parameters to distinguish asset versions. Cookie, geographic, and device-class headers can be stripped from the cache key for hashed assets without risk of serving stale content. This also means rolling back cache keys after a bad deploy is straightforward — the old hashed URL is simply left in place on the CDN while the HTML entry point is updated to reference the rollback filename.

Troubleshooting Common Failures

Phantom Hash Changes on Every Build

Symptom: Running the build twice on the same source code produces different output filenames.

Root causes and fixes:

  1. Non-deterministic plugin — a Babel plugin or PostCSS transform embeds a build timestamp or random UUID. Identify it with DEBUG=vite:build npm run build 2>&1 | grep -i "timestamp\|random\|uuid". Disable or replace the offending plugin.

  2. Unstable module ordering — Webpack without moduleIds: 'deterministic' assigns numeric IDs in insertion order, which varies with file-system enumeration order. Add the flag or upgrade to Webpack 5.

  3. Floating dependency versions — npm install without a lockfile can resolve different patch versions across runners. Use npm ci in CI, which installs strictly from package-lock.json.

  4. Source-map references in production — a trailing //# sourceMappingURL= comment is part of the emitted bytes, so a map filename that moves drags the bundle hash with it. Set sourcemap: 'hidden' or false in production builds.

The why deterministic builds matter for asset fingerprinting guide explains the reproducibility contract in depth, and source map hashes and debug builds covers the fourth case specifically.

Cache Misses After Correct Deployment

Symptom: Assets receive max-age=31536000, immutable headers but still show MISS at the CDN edge for minutes or hours.

Cold cache is expected behaviour. CDN edge nodes populate the cache on the first request from each geographic PoP. Every distinct PoP must receive at least one request before it caches the asset. There is no way to pre-warm all PoPs simultaneously using standard cache semantics — instead, use CDN-specific cache warming APIs or accept that cold misses after a deploy are a transient, not a misconfiguration.

A persistent MISS (hours after deployment) usually means the origin is returning a no-cache or private directive that overrides the S3 or Nginx configuration. Verify with a direct origin fetch using curl -sI https://origin.example.com/assets/main.a1b2c3d4.js.

Broken Module References in the Browser

Symptom: Console shows Failed to load module script or 404 for a chunk file that exists in the build output.

The HTML entry point contains references to hashed filenames that were generated during the build. If the HTML was deployed separately from the assets — or if the deploy was non-atomic and the old HTML was still live while new assets arrived — browsers may request the new filename before it is available, or request an old filename that was deleted by --delete in aws s3 sync.

Fix: upload all assets before uploading or invalidating HTML. In S3 pipelines, the sync of dist/assets/ must complete fully before the sync of dist/*.html. Verify sequence in your CI logs.

CDN Serving Stale HTML After Invalidation

Symptom: Users see stale content even after a successful CloudFront invalidation.

CloudFront invalidation removes files from every edge PoP, but it takes 30–60 seconds to propagate globally. During that window, some edges serve the old HTML referencing old asset filenames — which remain valid and cached, so the page loads correctly. This is by design. If stale HTML persists beyond 120 seconds post-invalidation, check that the invalidation was submitted to the correct distribution ID and that the paths include all HTML files, not just /index.html.

Common Failure Modes Reference

Issue Root Cause Resolution
Hash regenerates every build Non-deterministic plugin or floating deps npm ci, moduleIds: 'deterministic', sourcemap: false
CDN returns stale asset immutable header missing or overridden Check origin headers, verify CDN rule precedence
404 for newly deployed chunk Non-atomic deploy: HTML updated before assets Upload assets first, HTML last
Hit ratio drops after a header change Vary on a request header splits the object Restrict Vary to Accept-Encoding on hashed paths
SRI mismatch in browser Asset transformed by CDN (minification, re-encoding) Disable on-the-fly transforms; compute SRI after transformation
Reverse proxy strips hash from URL Proxy regex too restrictive Extend regex to match [0-9a-f]{8,} before extension

Pre-Deploy Checklist

Run this checklist before every release to production to catch configuration drift before it reaches users.

Frequently Asked Questions

Should I purge the entire CDN cache after deploying fingerprinted assets?

No. Only purge the HTML entry points. Fingerprinted assets are immutable — the URL itself encodes the content version, so the cached response is permanently correct by definition. Purging all assets forces every CDN edge to re-fetch from origin simultaneously, causing origin load spikes and elevated TTFB for minutes. The CDN purge strategies reference documents the correct per-CDN purge commands for HTML-only invalidation.

How does fingerprinting interact with HTTP/2 server push or Early Hints?

Positively. Unique URLs allow the server to push or hint multiple assets simultaneously without the browser rejecting them as duplicate-cache entries. HTTP/2 multiplexes all requests over a single connection, so there is no overhead from the long hash-bearing filenames. With 103 Early Hints, the server can emit the hashed asset URLs before the HTML fully renders, reducing waterfall depth by one round trip.

Can I use query string parameters instead of filename hashing?

Not reliably. Many CDNs, reverse proxies, and corporate firewalls normalise cache keys by stripping query strings entirely, treating main.js?v=a1b2c3d4 the same as main.js. Others preserve the query string but fragment the cache pool — every unique ?v= value becomes a separate cache entry, and a cache purge must target each individually. Filename mutation is unambiguous: the full path is the cache key on every compliant HTTP intermediary. The implementing cache keys with query parameters vs filenames guide demonstrates cache fragmentation with real CDN key examples.

What happens to old hashed assets after multiple deployments?

They remain on the CDN until they expire naturally (1 year TTL) or are explicitly purged. This is intentional. Users mid-session on an older build can continue to load old assets from CDN cache even after a new build has shipped. The CDN storage cost for 10–20 generations of hashed assets is negligible. If storage is a concern, schedule a weekly job to purge assets older than 30 days, but only after confirming no active sessions reference those filenames.

Do images and fonts really need fingerprinting too?

Yes, and they are the assets most often missed. A bundler hashes what it imports; anything copied verbatim from a static directory keeps a mutable URL, which means a logo swap or a font subset update can sit stale in browser caches for as long as the TTL you set. Because these files fail silently rather than throwing, the problem is usually discovered by a user, not by monitoring. Route them through the asset pipeline or a digest-renaming copy step, as described in hashing images, fonts, and media.

Why does my SRI hash mismatch even though the file content is identical?

The most common cause is CDN-level transformation applied after the file is uploaded. Some CDNs apply on-the-fly minification, Brotli re-encoding, or proprietary header injection that modifies the response body. The browser computes the SRI hash against the decoded body, so if the CDN re-encodes the response differently from how the SRI was computed, the check fails. Compute SRI hashes after any build-pipeline transformations and disable CDN-level body transforms on asset paths. The debugging SRI validation failures guide covers the full diagnostic sequence.