Fixing Missing Asset Hashes in Webpack 5

When Webpack 5 emits main.js instead of main.3f2a1b4c.js, every deployment overwrites the same CDN path and forces browsers to serve stale code until the edge cache expires — which under max-age=31536000 could mean up to a year of broken updates. This guide isolates the configuration states that suppress [contenthash], provides targeted diagnostic commands, and delivers the exact config changes needed to restore deterministic fingerprinting.

If you are setting up Webpack hashing from scratch rather than diagnosing an existing misconfiguration, start with the Webpack Output Hashing Setup reference first. For foundational context on why file-level fingerprinting outperforms manual versioning, see content hashing vs semantic versioning. For deterministic build outputs across CI environments, the hash stability prerequisites matter equally to the filename template itself.

Symptom Identification

The primary symptom is output filenames with no 8-character hex segment:

  • dist/assets/js/main.js — no hash
  • dist/assets/css/styles.css — no hash
  • dist/assets/images/logo.png — no hash

Secondary symptoms include CDN hit rates that do not drop after a deploy (the edge is serving the previous content under the same path) and CI pipelines that pass even though the hash-validation step expected fingerprinted filenames.

Diagnostic Commands

# List all files in dist/ that are missing an 8-char hex hash
find dist -type f | grep -vE '\.[a-f0-9]{8}\.'

# Cross-reference webpack verbose stats for chunk fingerprints
npx webpack --config webpack.config.js --mode production --stats verbose 2>&1 \
  | grep -E 'asset|chunk' | head -40

# Show raw output config as Webpack resolves it
npx webpack --config webpack.config.js --mode production --json \
  | python3 -c "import json,sys; cfg=json.load(sys.stdin); [print(a['name']) for a in cfg['assets']]"

Automated CI/CD Validation Script

Integrate this check into your pipeline to block deployments that contain unhashed artifacts:

#!/bin/bash
set -euo pipefail

DIST_DIR="${1:-./dist}"

UNHASHED=$(find "$DIST_DIR" -type f \
  \( -name '*.js' -o -name '*.css' -o -name '*.png' -o -name '*.woff2' \) \
  | grep -vE '\.[a-f0-9]{8}\.')

if [ -z "$UNHASHED" ]; then
  echo "All assets contain valid hashes."
else
  printf "ERROR: Missing hashes detected in the following files:\n%s\n" "$UNHASHED"
  exit 1
fi

Misconfigured vs Correctly Configured: A Comparison

The table below maps each root-cause configuration state to its build output and CDN consequence. Use it to identify which row matches your current output, then apply the corresponding fix.

Config State output.filename realContentHash Emitted Filename CDN Consequence
No hash placeholder '[name].js' any main.js Stale assets served on every deploy until TTL expires
Wrong hash type '[name].[hash:8].js' any main.a1b2c3d4.js (compilation-wide) All assets invalidated on every single file change
Hash pre-minification '[name].[contenthash:8].js' false main.3f2a1b4c.js but hash differs from minified output CDN path does not match the actual post-minified content
Correct — file-level '[name].[contenthash:8].js' true main.3f2a1b4c.js (content-scoped) Immutable caching; only changed files rotate their hash
Asset module no override assetModuleFilename: 'assets/[name][ext]' any assets/logo.png Image and font assets served stale forever
Asset module correct assetModuleFilename: 'assets/[name].[contenthash:8][ext]' true assets/logo.a4f6c0e8.png Immutable caching per asset

Misconfigured vs Correct Hash Pipeline

Misconfigured vs Correct Webpack Hash Pipeline Left panel shows a misconfigured pipeline where output.filename has no contenthash placeholder, resulting in static filenames and CDN cache never invalidating. Right panel shows the correct configuration with contenthash:8, producing fingerprinted filenames and proper cache invalidation. Misconfigured filename: '[name].js' | realContentHash: false src/index.js changed Webpack build no [contenthash] main.js no hash CDN serves stale main.js Same URL → cache never rotates → users get old code Must manually purge CDN on every deploy or wait for max-age TTL to expire Correctly Configured filename: '[name].[contenthash:8].js' | realContentHash: true src/index.js changed Webpack build [contenthash:8] main. 3f2a1b4c .js CDN serves new URL automatically New hash → new URL → cache miss → fresh content Old URL remains cached and valid for unchanged assets No manual CDN purge needed Webpack Hash Configuration: Misconfigured vs Correct
Left: missing contenthash placeholder causes static filenames and stale CDN delivery. Right: contenthash:8 with realContentHash: true produces unique URLs per content change.

Resolution: Enforcing Deterministic Hashing

Apply the corrected output and optimization blocks. The complete minimal fix is:

// webpack.config.js
module.exports = {
  mode: 'production',
  output: {
    filename: '[name].[contenthash:8].js',
    chunkFilename: '[name].[contenthash:8].chunk.js',
    assetModuleFilename: 'assets/[name].[contenthash:8][ext]',
    clean: true,
  },
  optimization: {
    moduleIds: 'deterministic',
    chunkIds: 'deterministic',
    realContentHash: true,
    runtimeChunk: 'single',
  },
};

Each option addresses a specific failure mode from the comparison table:

  • [contenthash:8] in filename — produces file-scoped fingerprints instead of compilation-wide or no hash.
  • realContentHash: true — defers hash computation until after Terser and CSSNano have run, so the hash matches the actual bytes on disk.
  • moduleIds: 'deterministic' / chunkIds: 'deterministic' — prevents module insertion order from changing numeric IDs between builds, which would silently change file content and rotate hashes even for unchanged source.
  • runtimeChunk: 'single' — extracts the Webpack runtime (the module registry) into its own tiny file, so it rotates on every build without dragging the vendor hash with it.
  • assetModuleFilename — applies the hash template to images, fonts, and other binary assets that asset module rules would otherwise emit without a fingerprint.

Hash length of 8 hex characters is the default recommendation. For monorepos with thousands of chunks, increase to 12–16 characters ([contenthash:12]) to reduce the statistical probability of collisions.

Verifying the Fix

# Run the production build
npx webpack --config webpack.config.js --mode production

# Expect: only index.html, manifest.json, and the runtime chunk appear without a content hash
find dist -type f | grep -vE '\.[a-f0-9]{8}\.'

# Run twice and diff to confirm hash stability across identical builds
npx webpack --config webpack.config.js --mode production
cp dist/manifest.json /tmp/m1.json
npx webpack --config webpack.config.js --mode production
diff /tmp/m1.json dist/manifest.json
# Expect: empty diff

CDN Cache Invalidation Verification

After deploying the corrected build, verify that immutable headers are in place and that the new hashed URLs are reachable:

# Confirm new hashed file is accessible and carries immutable headers
NEW_HASH_FILE=$(find dist/assets/js -name 'app.*.js' | head -1 | xargs basename)
curl -sI "https://cdn.example.com/assets/js/${NEW_HASH_FILE}" \
  | grep -i "cache-control"
# Expect: cache-control: public, max-age=31536000, immutable

Configure your CDN to purge only the HTML entry point on deploy. Hashed assets require zero manual invalidation because their URL changes with their content. For Cloudflare cache rules and purge and AWS CloudFront invalidation specifics, follow the CDN-specific guides.

Common Pitfalls

Issue Root Cause Resolution
realContentHash: false — hash mismatches after minification Webpack hashes the module graph, not the minified output Set optimization.realContentHash: true
clean: false leaves stale unhashed files in dist/ Incremental builds accumulate old artifacts Set output.clean: true or rm -rf dist before build
Custom assetModuleFilename overrides hash syntax Hardcoded paths bypass internal hashing Add [contenthash:8] to the asset filename template
Vendor hash rotates despite no node_modules change Missing runtimeChunk isolation Add optimization.runtimeChunk: 'single'
Hashes differ between local and CI build Non-deterministic module IDs or absolute paths in source maps Set moduleIds: 'deterministic' and use repo-relative devtoolModuleFilenameTemplate

When to Reconsider

Fixing the Webpack config is the right move in almost all cases. Reconsider this approach when:

  • Your project is migrating to Vite or esbuild. If the broader team has decided to move off Webpack, fixing hashing in the existing config adds technical debt to a codebase about to be replaced. Instead, route the fingerprinting work through the Vite asset pipeline configuration or esbuild fingerprinting plugins as part of the migration.
  • You are using a framework with opaque Webpack internals. Next.js, Create React App (ejected or not), and similar frameworks wrap Webpack with their own config merging logic. Editing webpack.config.js directly may be overridden by the framework. Use framework-specific configuration APIs (e.g., next.config.js webpack function) instead.
  • You need a rollback right now, not a config fix. If a bad deploy is live and users are broken, apply the rollback procedure first and fix the root-cause config in a follow-up build. See rolling back Webpack asset hashes after a bad deploy.

Frequently Asked Questions

Why does Webpack 5 sometimes output unhashed files in production mode?

The most common cause is output.filename set to '[name].js' without a hash placeholder. A second cause is conflicting configuration from a parent framework or webpack.config.merge call that overwrites the output block. Run --json and inspect the resolved output.filename field to confirm what Webpack actually uses at build time.

How do I ensure hashes remain stable across identical builds?

Set optimization.moduleIds: 'deterministic' and optimization.chunkIds: 'deterministic', enable realContentHash: true, and run two consecutive builds against the same source. Diff the manifests. If hashes differ, check whether any loader is embedding a timestamp, a random nonce, or an absolute path into module output.

Should I purge CDN cache on every deployment?

No. With immutable hashed filenames and Cache-Control: public, max-age=31536000, immutable, CDN edge nodes automatically serve fresh content via new URLs. Purge only if a deployment fails or a rollback is required and the HTML entry point must be refreshed before its TTL expires.

Does output.clean: true affect CDN caching?

No. clean: true removes stale files from the local dist/ directory before writing new hashed assets. It has no effect on CDN edge caches. Its only CDN-adjacent implication is that prior hashed files are deleted locally, so if you need to redeploy an old artifact you must retrieve it from CI artifact storage rather than the local directory.