Build Tool & Framework Asset Pipeline Integration

A complete reference for configuring Webpack 5, Vite 5, Rollup 4, esbuild 0.20+, Next.js 14, and Astro 4 to emit content-hashed filenames, generate deployment manifests, and wire into CI/CD pipelines for atomic CDN releases. Aimed at frontend engineers and release engineers who need exact configuration, not conceptual overviews.

Every bundler covered here embeds a cryptographic digest of each file’s contents into the output filename. When source bytes change, the hash changes, the URL changes, and CDN edges treat the new URL as a fresh resource — requiring no cache purge for the asset itself. This is the foundation of the immutable-cache pattern that lets you set Cache-Control: public, max-age=31536000, immutable on every fingerprinted file.

The Contract Every Bundler Implements

Underneath the configuration differences, all six tools implement the same four-step contract. They resolve a module graph from your entry points, split that graph into chunks, digest the final emitted bytes of each chunk into a short hex string, and write that string into the output filename. Everything downstream — the manifest, the HTML rewrite, the CDN upload, the edge TTL — is a consequence of those four steps being deterministic.

The pipeline below is the shape you are configuring, regardless of which tool sits in the middle band. Getting a bundler “working” usually means source files land in the output directory; getting it fingerprinted means every path out of the middle band is reproducible byte-for-byte from the same commit.

Fingerprinting pipeline from source to edge Source modules, stylesheets and media flow into a bundler pass that resolves the module graph, splits chunks and computes content hashes, producing hashed files, an asset manifest and rewritten HTML, all served from an immutable edge cache. From source module to immutable edge URL JS / TS entry modules src/main.ts · src/admin.ts Stylesheets CSS modules, SCSS, Tailwind Images, fonts, media png · woff2 · webp · mp4 Bundler pass — Webpack · Vite · Rollup · esbuild Module graph resolve · tree-shake Chunk split shared · dynamic Content hash sha-256 → 8 hex Hashed output files app-a1b2c3d4.js Asset manifest source path → CDN URL HTML + templates rewritten script tags Edge cache · immutable · max-age=31536000
The four-step contract: resolve the graph, split chunks, hash the emitted bytes, and hand three artefacts — files, manifest, rewritten HTML — to the edge.

What This Area Covers

This section is organised by build tool, because the failure modes are tool-specific: the flag that fixes a hash problem in Webpack has no analogue in esbuild, and the Astro fix lives two layers down in Vite’s Rollup options. Each guide below is a self-contained configuration reference, with focused pages underneath it for the incidents that actually page people at 3am.

Vite. The Vite asset pipeline configuration guide covers rollupOptions.output, build.manifest, and CDN base URLs. Start with configuring content hashing in Vite production builds for the from-scratch setup, rolling back a Vite asset hash after a bad deploy when a release has to be reverted, and Vite CSS code-split hash churn when your stylesheet hashes change on builds where no CSS was touched.

Webpack. The Webpack output hashing setup guide is the reference for output templates and deterministic IDs. Below it, fixing missing asset hashes in Webpack 5 handles assets that come out unhashed, rolling back Webpack asset hashes after a bad deploy covers reverting a release, and runtimeChunk and vendor hash invalidation explains why the vendor bundle keeps re-hashing when only application code changed.

Rollup. The Rollup asset optimization guide covers chunk strategy and emit hooks for library and application builds alike. Generating a Rollup asset manifest for CDN deploys supplies the plugin Rollup does not ship, and manualChunks and stable chunk hashes explains how a naive manualChunks function reshuffles chunk boundaries and destroys hash stability between builds.

esbuild. The esbuild fingerprinting plugins guide covers entryNames, assetNames, and plugin hooks. Integrating esbuild with CDN fingerprinting workflows walks the upload path, rolling back esbuild fingerprinted assets after a bad deploy covers reversion, and turning the esbuild metafile into an asset manifest converts esbuild’s build report into the normalised map the rest of your tooling expects.

Next.js. The Next.js static asset handling guide covers assetPrefix, header rules, and the internal Webpack overrides that survive upgrades. Next.js asset folder vs public directory hashing explains which files get a hash and which do not, rolling back Next.js static assets after a bad deploy handles reverting a release, and buildId and static chunk invalidation covers the one identifier Next.js regenerates on every build regardless of what changed.

Astro. The Astro build-time hashing guide documents the _astro/ output convention and the Vite escape hatch. Astro static asset optimization and fingerprinting covers image and font handling, and islands and client-directive asset hashes explains why a client:load component produces a hydration chunk whose hash is coupled to the framework runtime.

CI/CD. The CI/CD asset pipeline integration guide covers ordering, artefact retention, and verification gates. GitHub Actions hash manifest and atomic CDN deploy is the copy-paste workflow, rolling back fingerprinted assets in CI/CD handles automated reversion, and blue-green asset deploys with fingerprinted files covers running two complete asset sets side by side while traffic shifts.

Monorepos and micro-frontends. When several packages emit into one CDN namespace, hashing stops being a per-tool concern. The monorepo and micro-frontend hashing guide covers shared-namespace collision risk, workspace build ordering, and cross-package invalidation, with focused pages on sharing hashed chunks across Module Federation remotes, stopping hash drift between Turborepo cache hits, and per-package versus global asset manifests.

Why the Build Tool Layer Is the Critical Path

Cache invalidation strategies, CDN configuration, and deployment tooling all depend on the build tool doing its job correctly. If a bundler emits inconsistent hashes across two CI runners building the same commit, you get phantom hash changes that force unnecessary cache misses and complicate rollback. If it uses the wrong hash token — [hash] vs [contenthash] in Webpack — one changed module invalidates every chunk, shattering the cache.

The table below maps each major bundler to the hash token it uses in output templates, the algorithm, the default digest length, and whether it ships a manifest by default.

Bundler Hash token Algorithm Default length Manifest out of the box
Webpack 5 [contenthash] MD4 (configurable) 20 hex No — needs WebpackManifestPlugin
Vite 5 [hash] in rollupOptions.output SHA-256 (truncated) 8 hex Opt-in — build.manifest: true writes .vite/manifest.json
Rollup 4 [hash] SHA-256 (truncated) 8 hex No — manual plugin required
esbuild 0.20+ [hash] in assetNames/chunkNames SHA-256 (truncated) 8 hex Yes — metafile: true produces meta.json
Next.js 14 [contenthash] (via webpack internally) MD4 8 hex (via override) Yes — .next/build-manifest.json
Astro 4 [hash] (Rollup-based) SHA-256 (truncated) 8 hex Yes — dist/_astro/ with injected references

Use 8 hex digits (4 billion possible values) for projects with fewer than a few hundred chunks. For monorepos producing thousands of chunks in the same namespace, raise to 12–16 hex to reduce collision probability to negligible levels.

Webpack 5 Content Hashing

Webpack 5 ships with [contenthash] in its output template system. The distinction between [hash], [chunkhash], and [contenthash] is significant: [hash] is a build-wide hash that changes any time anything changes; [chunkhash] is per chunk but includes the chunk graph; [contenthash] is derived from the actual emitted bytes of each module, making it the only token suitable for long-term immutable caching.

// webpack.config.js
const { WebpackManifestPlugin } = require('webpack-manifest-plugin');
const path = require('path');

module.exports = {
  mode: 'production',
  entry: {
    main: './src/index.js',
    vendor: './src/vendor.js'
  },
  output: {
    path: path.resolve(__dirname, 'dist'),
    filename: 'assets/[name]-[contenthash:8].js',
    chunkFilename: 'assets/[name]-[contenthash:8].chunk.js',
    assetModuleFilename: 'assets/[name]-[contenthash:8][ext]',
    clean: true
  },
  optimization: {
    moduleIds: 'deterministic',
    chunkIds: 'deterministic',
    runtimeChunk: 'single'
  },
  plugins: [
    new WebpackManifestPlugin({
      fileName: 'asset-manifest.json',
      publicPath: process.env.CDN_BASE_URL || '/'
    })
  ]
};

Two optimization keys matter for determinism: moduleIds: 'deterministic' and chunkIds: 'deterministic'. Without them, Webpack assigns numeric IDs in discovery order, so adding a new module changes IDs — and therefore [contenthash] — of unrelated chunks. The runtimeChunk: 'single' extraction pulls the module loader out into its own file so that the mapping table it contains stops dirtying every chunk when any entry point changes.

The practical effect is measurable in the blast radius of a one-line source edit. With the defaults, editing a single component re-numbers modules and rewrites the inline runtime in every chunk, so every filename changes and every edge object becomes cold. With deterministic IDs and an extracted runtime, only the chunk containing the edited module — plus the tiny runtime file — gets a new hash.

Hash blast radius with and without deterministic IDs Two panels of six chunks each. With default module IDs all six chunks receive a new hash after a single module edit; with deterministic module IDs and an extracted runtime chunk only one chunk changes and the rest stay cached. Blast radius of a one-module edit Default module IDs moduleIds: deterministic app.js new hash vendor.js new hash ui.js new hash forms.js new hash chart.js new hash runtime.js new hash 6 of 6 chunks re-hashed app.js new hash vendor.js unchanged ui.js unchanged forms.js unchanged chart.js unchanged runtime.js unchanged 1 of 6 chunks re-hashed
The same one-module edit, twice: default IDs rewrite every filename, deterministic IDs plus an extracted runtime confine the change to one chunk.

The vendor bundle deserves particular attention, because it is the largest file most sites ship and the one users benefit most from keeping cached across releases. Splitting vendor code into its own chunk only helps if that chunk’s hash is genuinely stable; the interaction between splitChunks.cacheGroups, the extracted runtime, and module ID assignment is covered in depth in runtimeChunk and vendor hash invalidation.

The 8-character suffix yields 4 billion possible values — sufficient for projects with up to a few hundred simultaneous chunks. Raise to [contenthash:12] in monorepos where many teams produce thousands of chunks to a shared namespace.

Vite 5 Content Hashing

Vite wraps Rollup for production builds. Its [hash] token in rollupOptions.output is a SHA-256 digest of the module’s final emitted bytes, truncated to 8 hex characters by default. Enabling build.manifest writes a dist/.vite/manifest.json that maps every logical input path to its hashed output URL — this file is essential for server-side template rendering.

// vite.config.js
import { defineConfig } from 'vite';

export default defineConfig({
  build: {
    manifest: true,
    rollupOptions: {
      input: {
        main: 'src/main.js',
        admin: 'src/admin.js'
      },
      output: {
        entryFileNames: 'assets/[name]-[hash:8].js',
        chunkFileNames: 'assets/[name]-[hash:8].js',
        assetFileNames: 'assets/[name]-[hash:8][extname]'
      }
    },
    assetsDir: 'assets',
    assetsInlineLimit: 0,
    cssCodeSplit: true
  }
});

Setting assetsInlineLimit: 0 prevents small assets (below 4 KB by default) from being inlined as data URIs, which would strip the filename hash. Every file — even small SVG icons — gets an explicit fingerprinted URL, enabling individual cache control.

cssCodeSplit: true is Vite’s default and is usually correct, but it is also the most common source of unexplained hash movement: each async chunk gets its own stylesheet, and the extraction order of shared rules can shift when an unrelated import is added. If your CSS hashes move on builds that touched no styles, work through Vite CSS code-split hash churn before reaching for cssCodeSplit: false, which trades the problem for one large stylesheet that invalidates on every style change anywhere in the app.

Rollup 4 Manifest Generation

Rollup’s [hash] token operates at the chunk level. No built-in manifest exists, so a generateBundle hook is needed to emit one. The Rollup asset optimization guide covers tree-shaking and chunk strategy in more depth.

// rollup.config.js
function manifestPlugin() {
  return {
    name: 'manifest',
    generateBundle(options, bundle) {
      const manifest = {};
      for (const [fileName, chunk] of Object.entries(bundle)) {
        if (chunk.type === 'chunk' && chunk.facadeModuleId) {
          const inputKey = chunk.facadeModuleId.replace(process.cwd() + '/', '');
          manifest[inputKey] = options.dir + '/' + fileName;
        } else if (chunk.type === 'asset') {
          manifest[chunk.name] = options.dir + '/' + fileName;
        }
      }
      this.emitFile({
        type: 'asset',
        fileName: 'asset-manifest.json',
        source: JSON.stringify(manifest, null, 2)
      });
    }
  };
}

export default {
  input: { main: 'src/index.js', worker: 'src/worker.js' },
  output: {
    dir: 'dist',
    format: 'es',
    entryFileNames: 'assets/[name]-[hash:8].js',
    chunkFileNames: 'assets/[name]-[hash:8].js',
    assetFileNames: 'assets/[name]-[hash:8][extname]',
    manualChunks(id) {
      if (id.includes('node_modules')) {
        return 'vendor';
      }
      return null;
    }
  },
  plugins: [manifestPlugin()]
};

The manualChunks function above is deliberately coarse: every dependency lands in one vendor chunk. Finer-grained variants that key on package names produce better parallel loading but far less stable hashes, because adding one dependency can move modules between chunks and re-hash both. The trade-off, and the patterns that keep chunk boundaries stable across releases, are covered in manualChunks and stable chunk hashes.

esbuild 0.20+ Fingerprinting

esbuild’s assetNames, chunkNames, and entryNames options accept a [hash] placeholder that uses an 8-character SHA-256 truncation. The metafile: true flag produces a build report describing every input→output mapping, which serves as the source of truth for downstream CI steps.

// build.mjs
import * as esbuild from 'esbuild';
import { writeFileSync } from 'fs';

const result = await esbuild.build({
  entryPoints: {
    main: 'src/index.js',
    styles: 'src/styles.css'
  },
  bundle: true,
  minify: true,
  splitting: true,
  format: 'esm',
  outdir: 'dist/assets',
  assetNames: '[name]-[hash]',
  chunkNames: '[name]-[hash]',
  entryNames: '[name]-[hash]',
  metafile: true,
  define: {
    'process.env.NODE_ENV': '"production"'
  }
});

writeFileSync('dist/meta.json', JSON.stringify(result.metafile, null, 2));

// Derive a deployment manifest from the metafile
const manifest = {};
for (const [outPath, meta] of Object.entries(result.metafile.outputs)) {
  if (meta.entryPoint) {
    manifest[meta.entryPoint] = '/' + outPath;
  }
}
writeFileSync('dist/asset-manifest.json', JSON.stringify(manifest, null, 2));

esbuild does not support code splitting in CommonJS (cjs) format; use format: 'esm' with splitting: true for dynamic imports to get individual hashed chunks rather than one monolithic bundle. The metafile is a build report rather than a deployment manifest — its keys are working-directory-relative output paths, not the URLs your templates need — so the conversion step above is mandatory. Turning the esbuild metafile into an asset manifest covers the CSS sidecar entries and the entryPoint edge cases that the naive loop misses.

Next.js 14 Static Asset Handling

Next.js manages fingerprinting internally through its Webpack configuration. The _next/static/ path prefix receives immutable cache headers automatically in most deployment targets. When hosting on a custom CDN, override the assetPrefix to point at your origin, then apply Cache-Control: public, max-age=31536000, immutable at the CDN layer for everything under /_next/static/.

// next.config.js
/** @type {import('next').NextConfig} */
module.exports = {
  assetPrefix: process.env.CDN_BASE_URL || '',
  compress: false,
  generateBuildId: async () => {
    return process.env.GIT_COMMIT_SHA || 'development';
  },
  webpack: (config, { isServer }) => {
    if (!isServer) {
      config.output.filename = 'static/chunks/[name]-[contenthash:8].js';
      config.output.chunkFilename = 'static/chunks/[name]-[contenthash:8].chunk.js';
    }
    config.optimization.moduleIds = 'deterministic';
    return config;
  },
  async headers() {
    return [
      {
        source: '/_next/static/:path*',
        headers: [
          {
            key: 'Cache-Control',
            value: 'public, max-age=31536000, immutable'
          }
        ]
      }
    ];
  }
};

The generateBuildId override matters more than it looks. By default Next.js mints a random build ID per build, and that ID appears in the /_next/data/<buildId>/… route payload paths — so two builds of the same commit produce different URLs even when every chunk hash is identical. Pinning the build ID to the commit SHA makes the whole output reproducible; buildId and static chunk invalidation covers the interaction with client-side navigation and stale-tab recovery.

The public/ directory in Next.js is served at the root without hashing. Files in public/ must be managed with Cache-Control: no-cache or short TTLs, or renamed manually when their content changes. The build manifest at .next/build-manifest.json maps each page to its fingerprinted chunk dependencies — read it in your deployment scripts to verify that expected hashes appeared.

Astro 4 Build-Time Hashing

Astro uses Rollup under the hood for its production build, but abstracts the output template entirely. All script and style assets are emitted to dist/_astro/ with 8-character hashes appended, and HTML files reference them directly with the correct hashed paths. There is no explicit configuration required for hashing — running astro build is sufficient.

// astro.config.mjs
import { defineConfig } from 'astro/config';

export default defineConfig({
  build: {
    assets: '_astro',
    inlineStylesheets: 'never'
  },
  vite: {
    build: {
      rollupOptions: {
        output: {
          assetFileNames: '_astro/[name]-[hash:8][extname]',
          chunkFileNames: '_astro/[name]-[hash:8].js',
          entryFileNames: '_astro/[name]-[hash:8].js'
        }
      },
      assetsInlineLimit: 0
    }
  }
});

Setting inlineStylesheets: 'never' prevents Astro from inlining critical CSS into <style> tags. Inlined styles bypass fingerprinting entirely; keeping them in external files preserves the hash-on-change guarantee.

Astro’s interactive islands add a second hashing surface. Each client:load, client:idle, or client:visible directive emits a hydration entry point plus the framework runtime it depends on, so a React island and a Svelte island in the same project produce two independent runtime chunks with independent hashes. Upgrading the framework re-hashes every island that uses it even though no island source changed — see islands and client-directive asset hashes for how to keep that churn contained.

Monorepo and Micro-Frontend Pipelines

Single-application advice stops working once several packages publish to one CDN prefix. Three new problems appear. First, the collision surface grows with the number of chunks in the shared namespace, which is why 12–16 hex digits become the sensible default rather than 8. Second, remote-loaded modules — Module Federation remotes, or independently deployed micro-frontends — resolve their hashed URLs at runtime from a remote entry file, so the remote entry itself cannot be immutable. Third, remote build caches such as Turborepo will happily replay a cached dist/ from a different machine, and any environment sensitivity in your build turns that cache hit into silently different hashes.

{
  "globalEnv": ["NODE_ENV", "CDN_BASE_URL"],
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "env": ["VITE_API_URL"],
      "outputs": ["dist/**", "!dist/**/*.map"]
    }
  }
}

Declaring every environment variable that reaches the emitted bytes — under globalEnv when it affects all packages, env when it affects one — is what makes a cache hit safe. A variable that changes the output but is missing from these lists produces exactly the drift described in stopping hash drift between Turborepo cache hits. Whether each package should write its own manifest or contribute to one merged file is a deployment-topology question covered in per-package versus global asset manifests.

Manifest Generation and Format

Every build tool should produce a JSON manifest mapping logical asset names to their hashed URLs. This manifest is consumed in three places: server-side template rendering (inject the correct hashed URL into <script src=""> and <link href="">), deployment scripts (verify the expected files exist before switching traffic), and subresource integrity validation (compute and embed the integrity attribute). The format conventions and validation rules are treated in full in the asset manifest generation reference.

A normalized manifest format used across tools looks like:

{
  "src/index.js": "/assets/main-a1b2c3d4.js",
  "src/styles.css": "/assets/styles-e5f6a7b8.css",
  "src/logo.svg": "/assets/logo-c9d0e1f2.svg"
}

The keys are source-relative paths; the values are deployment-root-relative hashed URLs. When assetPrefix or a CDN origin is involved, store both the relative path (for local server resolution) and the absolute CDN URL as separate fields.

For SRI, extend the manifest to include the integrity value:

{
  "src/index.js": {
    "url": "/assets/main-a1b2c3d4.js",
    "integrity": "sha256-4REjAZCbTQhPtNuMrCGxStXoGJLl5OZJ8M2h3p6SWI="
  }
}

Compute SRI hashes at build time with:

openssl dgst -sha256 -binary dist/assets/main-a1b2c3d4.js \
  | openssl base64 -A \
  | awk '{print "sha256-" $0}'

CI/CD Integration

A robust CI/CD pipeline for fingerprinted assets has five sequential stages: build, verify hashes, upload assets, deploy HTML, invalidate HTML cache. The ordering is not stylistic — it is the only ordering in which no user can ever be served an HTML document referencing a file that does not yet exist at the edge.

Deploy sequence across CI, storage and CDN Five ordered stages: the CI runner builds and verifies the manifest, object storage receives immutable assets and then short-TTL HTML, and only afterwards does the CDN receive a purge request scoped to HTML. Deploy sequence — assets first, HTML last CI runner build + verify Object storage S3 · R2 · GCS CDN edge purge API 1 · Build npm run build 2 · Verify hash format 3 · Assets immutable 1y 4 · HTML no-cache 5 · Purge HTML only Purge runs last; hashed assets are already live at every edge.
Five stages across three actors — a purge issued before step 3 completes is the classic source of post-deploy 404s.

Here is a complete GitHub Actions workflow implementing that sequence against S3 and Cloudflare:

# .github/workflows/deploy.yml
name: Build and deploy

on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

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

      - name: Install dependencies
        run: npm ci

      - name: Build
        run: npm run build
        env:
          NODE_ENV: production
          CDN_BASE_URL: ${{ vars.CDN_BASE_URL }}

      - name: Verify manifest exists
        run: |
          test -f dist/asset-manifest.json || (echo "Manifest missing" && exit 1)
          node -e "
            const m = JSON.parse(require('fs').readFileSync('dist/asset-manifest.json', 'utf8'));
            const keys = Object.keys(m);
            if (keys.length === 0) { console.error('Empty manifest'); process.exit(1); }
            console.log('Manifest ok:', keys.length, 'entries');
          "

      - name: Verify hash format
        run: |
          node -e "
            const m = JSON.parse(require('fs').readFileSync('dist/asset-manifest.json', 'utf8'));
            for (const [src, url] of Object.entries(m)) {
              const urlStr = typeof url === 'string' ? url : url.url;
              if (!/[a-f0-9]{8}/.test(urlStr)) {
                console.error('Hash missing in:', urlStr);
                process.exit(1);
              }
            }
            console.log('All hashes present');
          "

      - name: Upload fingerprinted assets to S3
        run: |
          aws s3 sync dist/assets/ s3://${{ vars.S3_BUCKET }}/assets/ \
            --cache-control "public, max-age=31536000, immutable" \
            --metadata-directive REPLACE \
            --no-progress
        env:
          AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
          AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          AWS_DEFAULT_REGION: us-east-1

      - name: Upload HTML entry points to S3
        run: |
          aws s3 sync dist/ s3://${{ vars.S3_BUCKET }}/ \
            --exclude "assets/*" \
            --cache-control "no-cache, must-revalidate" \
            --metadata-directive REPLACE \
            --no-progress
        env:
          AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
          AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          AWS_DEFAULT_REGION: us-east-1

      - name: Purge HTML cache on Cloudflare
        run: |
          curl -s -X POST \
            "https://api.cloudflare.com/client/v4/zones/${{ secrets.CF_ZONE_ID }}/purge_cache" \
            -H "Authorization: Bearer ${{ secrets.CF_API_TOKEN }}" \
            -H "Content-Type: application/json" \
            --data '{"purge_everything":false,"files":["${{ vars.SITE_URL }}/","${{ vars.SITE_URL }}/index.html"]}' \
            | tee /tmp/cf_purge.json
          node -e "
            const r = JSON.parse(require('fs').readFileSync('/tmp/cf_purge.json', 'utf8'));
            if (!r.success) { console.error('Purge failed', JSON.stringify(r.errors)); process.exit(1); }
            console.log('Purge ok');
          "

Assets are immutable once on the CDN; the HTML document is the only pointer that needs a cache transition. Teams that need a longer soak before committing to a release keep two complete asset sets live and swap only the HTML — the mechanics are in blue-green asset deploys with fingerprinted files. Only the HTML cache requires purging; the CDN purge strategies reference covers Cloudflare, Fastly, CloudFront, and Nginx scenarios in detail.

CDN Propagation and Edge Behavior

After deployment, fingerprinted assets propagate to CDN edge nodes on first request from each region. With Cache-Control: public, max-age=31536000, immutable, edges cache the file for one year and skip revalidation entirely. The cache key architecture reference explains why filename-embedded hashes — rather than query parameters — achieve reliable edge caching. Most CDNs normalize query strings or ignore them entirely for cache keys; a unique filename is the only portable mechanism.

For Cloudflare, set a Cache Rule that applies to /_next/static/* or /assets/* patterns:

Cache Level: Cache Everything
Edge Cache TTL: 1 year (31536000 seconds)
Browser Cache TTL: Respect Existing Headers

For Nginx acting as an origin cache or reverse proxy:

location ~* ^/assets/.*\.[a-f0-9]{8}\.(js|css|svg|woff2|png|webp)$ {
    expires 1y;
    add_header Cache-Control "public, max-age=31536000, immutable";
    add_header Vary "Accept-Encoding";
    gzip_static on;
}

The regex [a-f0-9]{8} in the location block ensures that only fingerprinted paths receive the immutable header, preventing accidentally long-cached unversioned files.

For AWS CloudFront, apply a Cache Policy with Max TTL: 31536000 and Default TTL: 86400 to the /assets/* behavior, with origin headers forwarded. Unversioned paths (/index.html) need a separate behavior with Cache-Control: no-cache forwarded from the origin.

Verification Workflow

After deploying, confirm the pipeline produced valid hashes and the CDN is serving them correctly.

Step 1: Local build verification

# Confirm hashes are present in built filenames
ls -1 dist/assets/ | grep -E '[a-f0-9]{8}\.(js|css)$'

# Compare hash of the built file to expected
sha256sum dist/assets/main-a1b2c3d4.js

# Cross-check the manifest maps to an existing file
node -e "
  const fs = require('fs');
  const m = JSON.parse(fs.readFileSync('dist/asset-manifest.json', 'utf8'));
  let errors = 0;
  for (const [src, entry] of Object.entries(m)) {
    const url = typeof entry === 'string' ? entry : entry.url;
    const file = 'dist' + url;
    if (!fs.existsSync(file)) {
      console.error('Missing file for', src, '->', file);
      errors++;
    }
  }
  if (errors > 0) process.exit(1);
  console.log('All manifest entries resolve to files');
"

Step 2: CDN header inspection

# Inspect cache status from Cloudflare edge
curl -sI https://cdn.example.com/assets/main-a1b2c3d4.js \
  | grep -i -E 'cache-control|cf-cache-status|etag|age'

# Force a cold-cache miss to get a fresh edge response
curl -sI -H "Cache-Control: no-cache" \
  https://cdn.example.com/assets/main-a1b2c3d4.js \
  | grep -i 'cf-cache-status'

# Verify against multiple geographic edge IPs
for ip in 104.21.0.1 172.67.0.1; do
  echo "=== $ip ==="
  curl -sI --resolve cdn.example.com:443:$ip \
    https://cdn.example.com/assets/main-a1b2c3d4.js \
    | grep -i 'cf-cache-status'
done

Expected output: CF-Cache-Status: HIT after the first request warm-up. MISS on the first request is correct; EXPIRED or REVALIDATED on subsequent requests indicates the immutable directive was not applied.

Step 3: Hash drift detection between builds

# Build twice with identical source and compare manifests
npm run build && cp dist/asset-manifest.json /tmp/manifest-a.json
npm run build && cp dist/asset-manifest.json /tmp/manifest-b.json
diff /tmp/manifest-a.json /tmp/manifest-b.json

No diff means deterministic build outputs are working. Any diff reveals a non-deterministic plugin or timestamp injection problem.

Failure Modes and Gotchas

Wrong hash token in Webpack. Using [hash] instead of [contenthash] in Webpack output templates means every build produces a different hash for every chunk, regardless of what changed. All edges purge all assets on every deploy — defeating the immutability contract. Always use [contenthash] for JavaScript and CSS outputs.

Non-deterministic module IDs. Without moduleIds: 'deterministic' in Webpack 5, module IDs are assigned by discovery order. Adding, removing, or renaming any module shifts IDs and therefore hashes of unrelated chunks. This causes full-cache invalidation on what should be a minor code change.

Vite assetsInlineLimit inlining small files. The default 4 KB threshold causes small fonts and SVGs to be inlined as data URIs in the CSS. These inlined references cannot be individually cached or fingerprinted, and change the CSS hash every time any small asset changes. Set assetsInlineLimit: 0 unless you have profiled that the inline threshold improves performance for your specific page load pattern.

esbuild metafile output path confusion. The paths in result.metafile.outputs are relative to the current working directory, not to outdir. Construct the manifest paths carefully to avoid off-by-one prefix errors that send deployments to the wrong S3 key prefix.

Next.js public/ files have no hashing. Files under public/ are copied verbatim to the build output with their original filenames. Any cache-busting for these files must be done manually — either by versioning the filename, setting short TTLs, or using assetPrefix only for files under _next/static/.

A random Next.js build ID. Even with perfect chunk hashing, a randomly generated build ID changes the data-route URLs on every build and forces open tabs to re-fetch route payloads. Pin it to the commit SHA with generateBuildId.

Astro inlining stylesheets. With inlineStylesheets: 'auto' (the default), Astro inlines small stylesheets into <style> blocks. This removes them from the fingerprinted output and makes it impossible to cache them at the edge. Set inlineStylesheets: 'never' for consistent behavior.

Restored build caches that skip the hash step. Turborepo, Nx, and CI-level caches replay a previous dist/ instead of rebuilding. If an environment variable that reaches the emitted bytes is not declared as a cache input, the replayed output carries hashes that no longer match the current inputs — a defect that only surfaces on the machine that got the cache hit.

CDN stripping the immutable directive. Some CDN configurations — particularly default Nginx proxy setups — strip Cache-Control response headers and apply their own TTL. Verify the immutable token survives edge processing by running curl -sI against the CDN hostname, not the origin hostname.

Mixed-version asset serving during rolling deploy. If HTML uploads and asset uploads are not atomic — or if HTML is deployed before assets propagate — users may receive HTML referencing hashed asset URLs that do not yet exist on the CDN. Always upload assets before HTML, and verify propagation before switching traffic.

Pre-Deploy Checklist

Frequently Asked Questions

Which hash token should I use in Webpack 5 — [hash], [chunkhash], or [contenthash]?

Always use [contenthash] for JavaScript and CSS outputs. [hash] is a build-wide identifier that changes when anything in the build changes, so every file gets a new URL on every deploy regardless of whether its content changed. [chunkhash] is chunk-scoped but includes the chunk graph, so adding a new import to one chunk can dirty hashes of unrelated chunks. [contenthash] is derived from the emitted bytes of each individual output file, giving stable hashes for files whose content did not change.

Does Vite’s [hash] token produce the same hash across two identical builds?

Yes, provided the build inputs are deterministic. Vite delegates hashing to Rollup, which uses a SHA-256 digest of the final emitted bytes. If any plugin injects a timestamp, random value, or environment-specific path into the emitted bytes, the hash will differ. Pin plugin versions, set NODE_ENV=production, and test determinism by running npm run build twice and diffing the manifests.

Should I purge CDN cache for fingerprinted assets after deploying?

No. Fingerprinted assets have unique URLs by definition — a new URL is always a cache miss, so no purge is needed. Only purge the HTML entry points (and any non-fingerprinted files) that reference the new hashed asset URLs. Purging fingerprinted asset paths is harmless but wasteful. The CDN purge strategies reference covers the exact purge scope for HTML-only invalidation.

How do I implement subresource integrity alongside content hashing?

Generate SHA-256 hashes of each built file at build time, encode them as base64, and inject integrity="sha256-<base64>" attributes into the <script> and <link> tags that reference fingerprinted assets. Store the integrity values in the asset manifest alongside the hashed URLs. The subresource integrity validation guide covers generating SRI hashes in the build pipeline for each major bundler.

What happens if two different source files produce the same 8-character hash?

A hash collision at 8 hex digits has a 1-in-4-billion probability per pair of files — extremely unlikely in practice for projects with fewer than a few thousand chunks. If a collision occurs, the second file silently overwrites the first in the output directory without error from most bundlers. For monorepos with thousands of chunks, raise to [contenthash:12] or [contenthash:16]. The tradeoff and risk analysis are covered in preventing hash collisions in large frontend projects.

Can I use the same fingerprinting approach across multiple build tools in a monorepo?

Yes. Normalize on a shared manifest format — a JSON file mapping source paths to hashed output URLs — regardless of which bundler generated it. Each package runs its own build and writes its own manifest; a post-build script merges them. Use 12-character hashes when multiple packages emit assets to a shared CDN path prefix. The workspace-level concerns are covered in the monorepo and micro-frontend hashing guide.