Safely Truncating Content Hash Length

Bundlers truncate SHA-256 output to a short hex string that fits comfortably in a filename. The default is 8 hex characters across Webpack, Vite, and Rollup. That default is correct for most projects, but the risk it carries — two distinct files sharing the same fingerprint — scales with the number of assets in your build. This page derives the collision probabilities at 8, 12, and 16 characters, maps them to project sizes, and documents the exact configuration knobs in each major bundler.

How Truncation Works

SHA-256 produces a 256-bit digest, written as 64 hex characters. A bundler takes the first N characters of that hex string and embeds them in the filename:

sha256("body { color: red; }") = a3f2c4b1e9d0... (64 chars total)
truncated to 8:                  a3f2c4b1

The result is deterministic and content-derived: the same file bytes always produce the same N characters. The truncation reduces the output space from 2²⁵⁶ possible values to 2^(4N) possible values (each hex character encodes 4 bits).

Hex length Bits Distinct values
8 32 4,294,967,296 (~4.3 billion)
12 48 281,474,976,710,656 (~281 trillion)
16 64 18,446,744,073,709,551,616 (~18 quintillion)
64 (full) 256 2²⁵⁶ (astronomical)

The Birthday Bound

The birthday paradox says that in a set of N items drawn uniformly from a space of size M, the probability of at least one collision approaches 1 - e^(-N²/(2M)).

For fingerprinting purposes:

  • N = number of distinct assets in the build output (not files in the source tree — compiled chunks, CSS files, images, fonts).
  • M = 2^(4H) where H is the hex character length.

The approximation 1 - e^(-N²/(2M)) ≈ N²/(2M) is accurate when the probability is small.

Collision probability table

The table below gives the probability that at least one pair of assets shares the same truncated hash.

Assets (N) 8-char hash 12-char hash 16-char hash
100 0.00000116% ~0% ~0%
500 0.0000290% ~0% ~0%
1,000 0.000116% ~0% ~0%
5,000 0.00290% 0.0000000089% ~0%
10,000 0.0116% 0.0000000355% ~0%
50,000 0.290% 0.000000888% ~0%
100,000 1.15% 0.00000355% ~0%
500,000 27.4% 0.0000888% ~0%

At the default 8 characters, a project with 10,000 assets has roughly a 1-in-8,600 chance of a collision per build. That sounds small, but if you build 100 times per day, you expect a collision roughly every 86 days — a real operational risk.

At 12 characters, the same 10,000-asset project has a collision probability of 3.55 × 10⁻⁸ per build — effectively zero.

Collision probability by hash length A bar chart comparing collision probability at 8, 12, and 16 hex characters for three project sizes: 1000, 10000, and 100000 assets. The 8-char bars are visibly tall for large projects; 12- and 16-char bars are near-zero. Collision probability 0% 0.25% 0.5% 0.75% 1% 1,000 assets 10,000 assets 100,000 assets 1.15% 8-char hash 12-char hash 16-char hash
Collision probability at 8, 12, and 16 hex characters across three project sizes. At 100,000 assets, the 8-char default carries a ~1.15% collision risk per build; 12 characters reduces it to near-zero.

Practical Thresholds

Use the following thresholds as a starting point. “Assets” means distinct output files after the build: JS chunks, CSS files, images, fonts, and any other file the bundler fingerprints.

  • Under 2,000 assets: 8 characters is safe. Collision probability is below 0.001% per build.
  • 2,000 – 20,000 assets: 12 characters. The 8-character risk becomes non-trivial in large monorepos over many daily builds.
  • Over 20,000 assets: 16 characters. This covers the largest known frontend monorepos with many independently split micro-frontend chunks.

The default in Webpack, Vite, and Rollup is 8. Do not increase it unless your asset count pushes into the ranges above — longer hashes mean longer URLs, slightly larger HTML files, and marginally larger log lines. The cost is trivial but so is the reason to increase prematurely.

For context on how collisions manifest in production and how to detect them before deploy, see preventing hash collisions in large frontend projects.

Bundler Configuration

Webpack 5

Webpack 5’s [contenthash:N] token controls truncation length. The :N suffix is the number of hex characters.

// webpack.config.js — default 8 chars, or 12/16 for large projects
module.exports = {
  mode: 'production',
  output: {
    // Default: 8 characters
    filename: '[name].[contenthash:8].js',
    chunkFilename: '[name].[contenthash:8].chunk.js',
    assetModuleFilename: 'assets/[name].[contenthash:8][ext]',
    // For monorepos with 10k+ chunks, use 12:
    // filename: '[name].[contenthash:12].js',
    // chunkFilename: '[name].[contenthash:12].chunk.js',
    // assetModuleFilename: 'assets/[name].[contenthash:12][ext]',
    clean: true
  },
  optimization: {
    moduleIds: 'deterministic',
    chunkIds: 'deterministic',
    runtimeChunk: 'single'
  }
};

The same :N syntax applies to [hash] and [chunkhash] if you encounter them in older configs, but those tokens are superseded by [contenthash] in Webpack 5. For details on Webpack hashing, see the Webpack output hashing setup guide.

Vite

Vite uses Rollup for production builds. The hash length uses the same colon syntax in Rollup output patterns.

// vite.config.js — 8-char default; change to :12 or :16 for large builds
import { defineConfig } from 'vite';

export default defineConfig({
  build: {
    rollupOptions: {
      output: {
        entryFileNames: 'entry/[name]-[hash:8].js',
        chunkFileNames: 'chunks/[name]-[hash:8].js',
        assetFileNames: 'assets/[name]-[hash:8][extname]'
      }
    }
  }
});

Vite also exposes build.assetsDir (default 'assets') which prefixes the asset path but does not affect the hash length. Increase :8 to :12 in all three patterns together to keep them consistent. For the full Vite configuration reference, see how to configure content hashing in Vite production builds.

Rollup 4

Rollup 4 defaults to 8 hex characters in [hash]. Length is controlled inline in the pattern string.

// rollup.config.js — explicit 8-char hash length
export default {
  input: 'src/index.js',
  output: {
    dir: 'dist',
    format: 'es',
    entryFileNames: '[name]-[hash:8].js',
    chunkFileNames: 'chunks/[name]-[hash:8].js',
    assetFileNames: 'assets/[name]-[hash:8][extname]'
  }
};

Rollup 4 also has an output.hashCharacters option that controls the character set ('hex', 'base64', 'base36'). The default 'hex' produces lowercase a–f 0–9. Base64 produces shorter strings (6 chars covers the same space as 8 hex), but the +, /, and = characters are URL-unsafe and require encoding in filenames.

// rollup.config.js — base36 for shorter, URL-safe hashes
export default {
  input: 'src/index.js',
  output: {
    dir: 'dist',
    format: 'es',
    hashCharacters: 'base36',
    entryFileNames: '[name]-[hash].js',   // 8 chars is the default length
    chunkFileNames: 'chunks/[name]-[hash].js'
  }
};

Stick with 'hex' unless URL length is a genuine constraint. Base36 and base64 produce hashes that are harder to inspect manually.

esbuild

esbuild’s hash length is fixed at 8 hex characters and is not configurable. The [hash] token in entryNames, chunkNames, and assetNames always produces an 8-character result.

// esbuild — hash length is always 8, not configurable
const esbuild = require('esbuild');

esbuild.build({
  entryPoints: ['src/app.js'],
  bundle: true,
  splitting: true,
  format: 'esm',
  outdir: 'dist',
  entryNames: '[name]-[hash]',
  chunkNames: 'chunks/[name]-[hash]',
  assetNames: 'assets/[name]-[hash]'
});

If your esbuild-based build produces enough assets to require longer hashes, you have two options:

  1. Post-process the output with a script that renames files using a fresh SHA-256 truncated to 12 or 16 characters and rewrites the manifest.
  2. Prefix asset paths with a content-derived subdirectory (e.g., the first 4 characters of the hash) to reduce the effective collision space within any prefix.

Option 1 is straightforward for most pipelines. See integrating esbuild with CDN fingerprinting workflows for a complete post-processing example.

Verifying Hash Length in Build Output

After configuring a new hash length, verify the output before deploying:

# Count assets and inspect the fingerprint lengths in the dist output
find dist/ -type f | while read f; do
  base=$(basename "$f")
  # Extract the hex fingerprint segment (8-16 hex chars between - or . and the extension)
  hash=$(echo "$base" | grep -oE '[a-f0-9]{8,16}')
  echo "${#hash} chars: $hash  $f"
done | sort | uniq -c | sort -rn | head -20

All fingerprints should be the same length. Mixed lengths indicate that some output patterns were not updated when you changed the length setting.

# Check for duplicate fingerprints (collision detection pre-deploy)
find dist/ -type f | while read f; do
  echo "$f" | grep -oE '[a-f0-9]{8,16}'
done | sort | uniq -d

No output means no duplicates. Any duplicated hash string indicates a collision that must be resolved before the deploy reaches the CDN.

Changing Hash Length Mid-Project

Changing the hash length is not a breaking change in itself, but it will invalidate all currently cached assets because every filename changes. Plan the rollout:

  1. Update the bundler config with the new length.
  2. Do a full production build.
  3. Deploy the new files to the CDN origin before deploying the updated HTML.
  4. Deploy the HTML last (or purge the HTML cache immediately after).

This is the same atomic deploy sequence required for any hash change — the length change is not special. The transition period (between deploying new files and deploying the HTML that references them) is the window during which both old and new files must be available at origin. See implementing cache keys with query parameters vs filenames for the sequencing detail.

When to Reconsider

Stay at 8 characters when:

  • Your build produces fewer than 2,000 output files.
  • You deploy once daily or less.
  • Shortening URL length is a meaningful constraint (e.g., serving from a CDN that imposes strict URL length limits).

Move to 12 characters when:

  • You have a monorepo with 10+ independently deployed apps sharing a build pipeline.
  • You build and deploy more than 50 times per day.
  • Your QA tooling or CDN logging starts reporting hash collisions.

Move to 16 characters when:

  • Your build output exceeds 20,000 distinct fingerprinted files.
  • You have SLA commitments that make even a 1-in-10,000 collision risk unacceptable.
  • You are building a platform or framework that controls hashing for hundreds of downstream projects.

Frequently Asked Questions

Does truncating SHA-256 to 8 characters reduce the security of SRI?

SRI uses the full untruncated hash — the 8-character filename portion is irrelevant to SRI. The integrity attribute always contains a base64-encoded full SHA-256 (or SHA-384, SHA-512) digest. The filename fingerprint and the SRI hash serve different purposes and must never be confused.

Does a longer hash affect CDN performance or routing?

Negligibly. CDN edge nodes hash cache keys internally; a URL that is 8 characters longer does not measurably affect lookup time. URL length only becomes relevant when individual URLs approach CDN-enforced limits (typically 8,192 bytes — far beyond any realistic asset path).

Can two builds of the same code produce different truncated hashes?

Only if the build is non-deterministic. If the underlying SHA-256 digest differs between builds for the same source file (due to timestamps, module ID shuffling, etc.), the truncated value also differs. Fixing non-determinism is prerequisite to trusting any hash length. See deterministic build outputs for the diagnostic workflow.

Should I use base64 to get shorter hashes at the same entropy level?

Base64 is 6 bits per character vs 4 bits per character for hex, so 8 base64 characters holds 48 bits (equivalent to 12 hex characters). Rollup supports hashCharacters: 'base64', but base64 includes +, /, and = which are not safe in filenames and URLs without encoding. Base36 (a–z 0–9) is URL-safe and is available in Rollup, but it is not supported natively in Webpack or Vite. The complexity rarely justifies the marginal URL-length saving.