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
Which Layer Actually Set output.filename
The second most common cause, after a plainly missing placeholder, is a correct placeholder that something later overwrites. Webpack resolves output.filename from a stack of contributions, and the last writer wins silently — there is no warning when a webpack-merge overlay or a framework plugin replaces a template you wrote.
Print what the compiler actually used rather than trusting the file you edited:
# Dump the resolved configuration Webpack ran with
npx webpack --config webpack.config.js --mode production --json build-stats.json
python3 - build-stats.json <<'PY'
import json, sys
stats = json.load(open(sys.argv[1]))
print("outputPath:", stats.get("outputPath"))
for asset in stats.get("assets", []):
print(f" {asset['name']} {asset['size']} bytes")
PY
Every emitted asset name appears in that listing. If the names lack a hex segment while your webpack.config.js clearly contains [contenthash:8], the template you wrote is not the template that ran. Search the repository for competing writers:
# Find every place that assigns an output filename template
grep -rn --include='*.js' --include='*.cjs' --include='*.mjs' --include='*.ts' \
-E "(filename|chunkFilename|assetModuleFilename)\s*:" . \
--exclude-dir=node_modules --exclude-dir=dist
webpack-merge’s default merge() concatenates arrays but overwrites scalars, so any preset that declares output.filename beats yours if it is passed second. Reverse the argument order, or use mergeWithCustomize with a customizeObject rule that keeps your value.
Asset Modules That Emit No File at All
A third class of missing hash is not a naming problem — the file is never emitted. Webpack 5’s type: 'asset' rule chooses between asset/resource and asset/inline based on parser.dataUrlCondition.maxSize, which defaults to 8192 bytes. Anything under that threshold becomes a base64 data URI inside the importing chunk, so it has no filename and therefore no hash of its own.
If your validation script reports that logo.png has no hash but find dist -name 'logo*' returns nothing, this is the cause. Decide deliberately rather than by threshold accident:
// webpack.config.js — explicit routing per asset class
module.exports = {
module: {
rules: [
{
// Small UI glyphs: inline on purpose, no separate request
test: /\.svg$/i,
type: 'asset/inline',
},
{
// Everything raster: always a hashed file, whatever the size
test: /\.(png|jpe?g|gif|webp|avif)$/i,
type: 'asset/resource',
generator: {
filename: 'assets/images/[name].[contenthash:8][ext]',
},
},
{
// Fonts: always a hashed file, and never inlined
test: /\.(woff2?|ttf|otf|eot)$/i,
type: 'asset/resource',
generator: {
filename: 'assets/fonts/[name].[contenthash:8][ext]',
},
},
],
},
};
Inlining is a legitimate choice for assets under about 2 KB, where an extra request costs more than the 33% base64 overhead. It is the wrong choice for anything a user might cache across releases, because an inlined asset inherits the parent bundle’s cache lifetime and rotates whenever any sibling module changes.
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.
CSS That Never Gets a Hash
Extracted stylesheets have their own filename template, and it is easy to fix the JS side while leaving CSS unhashed. MiniCssExtractPlugin does not inherit output.filename; it takes filename and chunkFilename options of its own, and if you omit them it falls back to '[name].css'. Two further traps sit behind that:
style-loaderemits no file at all. In a config wherestyle-loaderis used in production instead ofMiniCssExtractPlugin.loader, every rule ends up injected at runtime through a<style>tag, so there is no stylesheet to fingerprint and no way to cache it separately from the JS.- Inside a CSS file,
url()references are resolved bycss-loaderand re-emitted through the asset module rules. If those rules are missing agenerator.filename, the images a stylesheet points at stay unhashed even when the stylesheet itself is fingerprinted correctly.
// webpack.config.js — CSS extraction with its own hash template
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
module.exports = {
mode: 'production',
module: {
rules: [
{
test: /\.css$/i,
use: [
MiniCssExtractPlugin.loader,
{ loader: 'css-loader', options: { importLoaders: 1 } },
],
},
],
},
plugins: [
new MiniCssExtractPlugin({
filename: 'assets/css/[name].[contenthash:8].css',
chunkFilename: 'assets/css/[name].[contenthash:8].chunk.css',
}),
],
};
Keep the hash length identical across JS, CSS, and media templates. A mixed setup — 8 characters on JS and 20 on CSS — still works, but it defeats the single CDN rule that matches \.[a-f0-9]{8}\. and forces you to maintain one pattern per asset class at the edge.
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.
The hash is present but it changes on every build — is that the same bug?
No, that is the opposite failure and it has a different root cause. A hash that is present but unstable points at module or chunk id churn, or at a loader writing volatile data into module output. The diagnosis and fix live in runtime chunk and vendor hash invalidation, which walks through extracting the runtime and pinning deterministic ids.
Should the runtime chunk and manifest.json carry hashes too?
The runtime chunk should — it is a normal chunk and rotates often, so an immutable hashed URL saves a revalidation round trip. manifest.json should not: it is the pointer that everything else is discovered through, so it must be served with no-cache, must-revalidate under a stable name, exactly like the HTML entry point.
Related
- Webpack Output Hashing Setup — parent page covering the full configuration reference and implementation steps
- Runtime chunk and vendor hash invalidation — the companion failure mode where the hash exists but will not hold still
- 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