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

Two Webpack configs, one edited line Left panel: with filename set to name.js the same URL is emitted on every build, the edge keeps serving the old file, and recovery means purging a path prefix. Right panel: with contenthash in the template each content change produces a new URL and no purge is needed. Two Webpack configs, one edited line Missing placeholder filename: '[name].js' src/index.js one line edited webpack build no [contenthash] main.js no hash Edge keeps serving the old main.js same URL means same cache key Recovery: purge the whole path prefix on every release, or wait out max-age Content hash present filename: '[name].[contenthash:8].js' src/index.js one line edited webpack build realContentHash main. 9a7d2e1f.js New URL on every content change the cache miss is automatic and scoped Recovery: none needed, unchanged files keep their existing hashed URL
Left: a missing placeholder collapses every release onto one URL. Right: contenthash:8 with realContentHash gives each distinct byte sequence its own permanent address.

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.

Where output.filename actually comes from Four layers stacked top to bottom: webpack defaults supply name.js, webpack.config.js supplies the contenthash template, a webpack-merge overlay replaces it with name.js again, and the compiler-resolved value is the unhashed template that gets emitted. Where output.filename actually comes from later config layers silently overwrite earlier ones webpack defaults '[name].js' default webpack.config.js '[name].[contenthash:8].js' intended webpack-merge overlay '[name].js' from a shared preset wins compiler-resolved value '[name].js' emitted
A correct template in webpack.config.js is worthless if a later overlay replaces the whole output block — always read the resolved value, not the source 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.

Asset module type decides whether a hash exists One 6 kilobyte PNG fans out to three rules. asset/resource always emits a hashed file. The automatic asset type inlines anything under 8 kilobytes so no file exists. asset/inline always produces a base64 data URI with no independent cache key. Asset module type decides whether a hash exists logo.png 6 KB type: 'asset/resource' always emits a file logo.a4f6c0e8.png hashed, immutable, cacheable type: 'asset' inlines under 8 KB inlined into the JS bundle no file, no hash of its own type: 'asset/inline' always base64 no file emitted at all cache key is the parent bundle
A missing hash on an image is often a missing file: the automatic `asset` type inlines anything below its 8 KB threshold, so nothing is ever written to disk.

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] 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.

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-loader emits no file at all. In a config where style-loader is used in production instead of MiniCssExtractPlugin.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 by css-loader and re-emitted through the asset module rules. If those rules are missing a generator.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.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.

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.