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 hashdist/assets/css/styles.css— no hashdist/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
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]infilename— 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.jsdirectly may be overridden by the framework. Use framework-specific configuration APIs (e.g.,next.config.jswebpackfunction) 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.
Related
- Webpack Output Hashing Setup — parent page covering the full configuration reference and implementation steps
- Rolling back Webpack asset hashes after a bad deploy — recovery procedure when a broken build is already live
- Deterministic build outputs — why stable hashes across identical builds matter for CDN and CI correctness
- Content hashing vs semantic versioning — foundational comparison that explains why filename fingerprinting outperforms manual version strings
- Build Tool & Framework Asset Pipeline Integration — overview of all bundler hashing approaches across Webpack, Vite, esbuild, and Rollup