xxHash and Non-Cryptographic Hashes in Bundlers

Modern bundlers reach for xxHash64 and xxHash3 rather than SHA-256 when they need a content digest, and that choice is correct — right up to the moment someone pipes the resulting value into an integrity attribute. This page explains what the speed difference actually buys on a large build, how each bundler is configured, why an 8-character xxHash filename is as safe as an 8-character SHA-256 filename, and the one place a non-cryptographic digest must never appear.

The Decision in One Sentence

A hash that only has to distinguish your own files from each other can be non-cryptographic; a hash that has to prove to a browser that nobody tampered with the bytes cannot. Filenames fall in the first category, subresource integrity attributes in the second, and the two digests are computed by different code paths even when they cover the same file.

Where Build Time Actually Goes

Hashing looks free in a small project because it is: a 6 MB output tree hashes in single-digit milliseconds with any algorithm. It stops being free in three situations that compound in large repositories.

First, the digest is computed more than once per file. Webpack hashes the pre-minified chunk, then re-hashes the minified result when optimization.realContentHash is on, so the byte volume passing through the hash function is roughly double the size of dist/.

Second, hashing is not only applied to output files. Bundlers hash module source, loader configuration, and resolver results to build persistent-cache keys. A 4,000-module application performs tens of thousands of small digest operations per build, where per-call overhead matters more than raw throughput.

Third, CI runners are usually the slowest hardware in the loop. A shared runner without SHA extensions enabled loses roughly a third of SHA-256’s throughput, while xxHash — which is plain integer arithmetic with no hardware dependency — is unaffected.

Time to digest 500 MB by algorithm Horizontal bars comparing single-core wall-clock time to hash five hundred megabytes: SHA-256 takes about 0.71 seconds, MD5 about 0.42, BLAKE3 about 0.12, xxHash64 about 0.06 and xxHash3 about 0.03 seconds. Wall-clock time to digest 500 MB on one core SHA-256 0.71 s MD5 0.42 s BLAKE3 0.12 s xxHash64 0.06 s xxHash3 0.03 s Approximate, single x86_64 core with SHA-NI available to SHA-256
An order of magnitude separates the SHA-2 family from the xxHash family. On a build that hashes half a gigabyte twice, that is roughly 1.3 seconds against 0.12.

You can put a number on your own build before touching any config. The command below streams every emitted file through SHA-256 once and reports the elapsed time, which is a lower bound on what the digest costs you per pass:

# Total bytes in the output tree, then one full SHA-256 pass over it
du -sh dist
time sh -c 'find dist -type f -exec cat {} + | openssl dgst -sha256 > /dev/null'

Double the reported time to approximate a production Webpack build with realContentHash enabled, then compare it against the total build duration. Under one percent means the algorithm is not worth changing; ten percent on a build that runs on every pull request is a different conversation.

Half a second here and a second there is not why teams switch. They switch because a monorepo build that runs 300 times a day pays the cost 300 times, and because the same digest function backs the persistent cache — so a faster hash makes warm rebuilds noticeably snappier, not just cold ones. Measure before you tune: if --profile shows hashing below one percent of total build time, the algorithm is not your problem.

Algorithm Comparison

Algorithm Digest size Throughput (1 core) Collision resistance SRI-eligible
xxHash3 64 or 128 bit ~15 GB/s Statistical only — trivially broken by an adversary No
xxHash64 64 bit ~8 GB/s Statistical only — trivially broken by an adversary No
BLAKE3 256 bit (variable) ~4 GB/s Cryptographic, 2¹²⁸ No — not in the W3C algorithm list
MD5 128 bit ~1.2 GB/s Broken for deliberate collisions No
SHA-1 160 bit ~900 MB/s Broken for deliberate collisions No
SHA-256 256 bit ~700 MB/s Cryptographic, 2¹²⁸ Yes
SHA-384 / SHA-512 384 / 512 bit ~500 MB/s Cryptographic Yes

“Statistical only” is the row that matters. xxHash passes the SMHasher distribution suite, which means it spreads unrelated inputs evenly across its output space — exactly what cache-busting needs. It offers no resistance at all to someone deliberately constructing two inputs with the same digest, which takes seconds on a laptop.

Bundler Defaults and Options

Hash configuration surface by bundler Three cards. Webpack 5 exposes output.hashFunction, defaulting to a WebAssembly md4 and accepting xxhash64, sha256 and sha512. Rspack and Turbopack use xxHash64 with no cryptographic mode. Vite and Rollup 4 expose no algorithm option at all, only the hash token length. Webpack 5 output.hashFunction default: md4 in wasm accepts xxhash64 also sha256, sha512 Rspack, Turbopack xxHash64 by default speed is the point no cryptographic mode SRI needs a post-step Vite, Rollup 4 no algorithm option length via [hash:8] alphabet via hashCharacters All three need a separate SHA-256 pass to produce integrity attributes
Only Webpack lets you name the algorithm. Everywhere else the filename digest is an implementation detail you should treat as opaque.

Webpack 5 is the only mainstream bundler that exposes the algorithm directly. output.hashFunction accepts any string Node’s crypto.createHash understands, plus the two implementations Webpack ships itself: a WebAssembly md4 (the default, chosen for speed rather than for any cryptographic property) and 'xxhash64'. Setting it changes every hash Webpack computes, including [contenthash], module IDs derived from hashes, and persistent-cache keys.

Rspack and Turbopack use xxHash64 internally and do not offer a cryptographic option. This is deliberate: both are optimised for build latency, and neither treats the filename digest as a security boundary.

Vite and Rollup 4 expose no algorithm setting at all. You control the length with [hash:8] and, in Rollup, the alphabet with output.hashCharacters. The underlying function is an implementation detail that has changed across major versions, which is the practical reason never to assume a Vite filename hash is a SHA-256 prefix — write your integrity step against the emitted bytes instead of against the filename.

esbuild likewise uses a fast internal hash, fixed at 8 hex characters, with no configuration surface.

The Rule: Filenames Yes, Integrity Never

The failure mode here is quiet, which is what makes it dangerous. The SRI specification says a browser evaluates only the strongest supported algorithm present in the integrity attribute, and that an attribute containing no recognised algorithm carries no valid metadata — so the resource loads without any check at all. Write integrity="xxh64-4f2a9c71a8b30d15" and you do not get an error in the console; you get a page that silently no longer validates anything, while every code review sees an integrity attribute and assumes the asset is protected.

Two digests, one forbidden shortcut The emitted chunk feeds two independent paths. The upper path hashes with xxHash64 to build the filename. The lower path hashes the same bytes with SHA-256 to build the integrity attribute. A crossed-out link between the filename value and the integrity attribute marks the reuse that must never happen. Emitted chunk post-minification bytes Filename digest xxHash64, 8 hex chars app.4f2a9c71.js cache-busting only Integrity digest SHA-256, full length integrity attribute checked by the browser never reuse the filename hash
The same bytes, two digests, two consumers. The crossed link is the mistake: a filename hash is truncated and non-cryptographic, so it cannot serve as integrity metadata.

The same restriction applies in reverse to lockfiles and internal caches: npm records sha512- integrity strings for tarballs because that value is a trust boundary, while a Turborepo or Nx remote-cache key can happily be an xxHash because it only has to identify an artefact your own CI produced. The question to ask of any digest is always the same — who could benefit from forging this value, and can they supply the input?

Collision Behaviour at 8, 12 and 16 Hex Characters

For accidental collisions the algorithm is nearly irrelevant; what matters is how many bits survive truncation. An 8-character xxHash64 filename and an 8-character SHA-256 filename both leave 32 bits, and both carry the same birthday-bound risk for the same asset count. The numbers per project size are tabulated in the guide to safely truncating content hash length.

Two differences are specific to xxHash. First, 16 hex characters is the entire xxHash64 digest — there is no seventeenth character, so a build that outgrows 64 bits has to move to xxHash3’s 128-bit mode or to a SHA-2 digest rather than simply widening the token. Second, the adversarial case flips completely. If any hashed input is attacker-controlled — user-uploaded media that your pipeline fingerprints, or a plugin ecosystem where third parties contribute source files — an attacker can craft two files with an identical xxHash and choose which one your CDN ends up serving. In that threat model the filename hash is a security boundary and must be cryptographic.

Configuration

Webpack 5, switching the algorithm and generating integrity separately:

// webpack.config.js
const { SubresourceIntegrityPlugin } = require('webpack-subresource-integrity');

module.exports = {
  mode: 'production',
  output: {
    filename: '[name].[contenthash:8].js',
    chunkFilename: '[name].[contenthash:8].chunk.js',
    assetModuleFilename: 'assets/[name].[contenthash:8][ext]',
    // Fast, non-cryptographic digest for every hash Webpack computes.
    hashFunction: 'xxhash64',
    hashDigest: 'hex',
    hashDigestLength: 8,
    crossOriginLoading: 'anonymous',
    clean: true
  },
  optimization: {
    moduleIds: 'deterministic',
    chunkIds: 'deterministic',
    runtimeChunk: 'single',
    realContentHash: true
  },
  plugins: [
    // Independent of hashFunction: always emits sha384 metadata.
    new SubresourceIntegrityPlugin({ hashFuncNames: ['sha384'] })
  ]
};

hashDigestLength: 8 sets the fallback length; the explicit :8 in each template wins where present. Note that crossOriginLoading: 'anonymous' is required for SRI on cross-origin chunks, and that the integrity plugin ignores hashFunction entirely — that separation is the whole point.

Vite 5, where the algorithm is not configurable and integrity is a post-build pass:

// vite.config.js
import { defineConfig } from 'vite';
import { createHash } from 'node:crypto';
import { readFileSync, readdirSync } from 'node:fs';
import { join } from 'node:path';

function sriManifest(outDir = 'dist') {
  return {
    name: 'sri-manifest',
    apply: 'build',
    closeBundle() {
      const dir = join(outDir, 'assets');
      const entries = {};
      for (const file of readdirSync(dir)) {
        const bytes = readFileSync(join(dir, file));
        const digest = createHash('sha384').update(bytes).digest('base64');
        entries[`/assets/${file}`] = `sha384-${digest}`;
      }
      console.log(JSON.stringify(entries, null, 2));
    }
  };
}

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

The plugin runs after Rollup has written the final files, so it hashes exactly the bytes the CDN will serve. Hashing earlier — in generateBundle, before any post-processing — is the most common source of integrity mismatches.

Verification

Confirm what your build is actually emitting: compare the filename fingerprint against a SHA-256 prefix of the same file. A mismatch proves a non-cryptographic digest is in play, and the command prints the integrity string you should be using instead.

# Point at any fingerprinted output file
FILE=$(find dist -name '*.js' -type f | head -n 1)

node -e '
const { createHash } = require("node:crypto");
const { readFileSync } = require("node:fs");
const file = process.argv[1];
const bytes = readFileSync(file);
const sha = createHash("sha256").update(bytes).digest("hex");
const stamp = (file.match(/[-.]([a-f0-9]{8,16})\./) || [])[1];
console.log("file          :", file);
console.log("filename stamp:", stamp);
console.log("sha256 prefix :", sha.slice(0, (stamp || "").length));
console.log(stamp && sha.startsWith(stamp)
  ? "→ filename hash IS a SHA-256 prefix"
  : "→ filename hash is NOT SHA-256 (xxHash or md4) — never reuse it for SRI");
console.log("integrity     : sha384-" +
  createHash("sha384").update(bytes).digest("base64"));
' "$FILE"

Run it against a Vite build and an Rspack build and you will get the “NOT SHA-256” branch on both. The integrity line it prints is the value that belongs in your HTML.

When to Reconsider

Stay on SHA-256 for the filename digest when any of the following hold, even though it costs build time:

  • Attacker-supplied input. Anything user-uploaded, or contributed by a third-party plugin, that your pipeline fingerprints into a served URL.
  • Regulated environments. FIPS 140-2 and PCI DSS controls are written against algorithm allow-lists, and xxHash appears on none of them. Auditors do not distinguish between “used for security” and “used for filenames”.
  • Cross-team artefact sharing. If another team’s deploy pipeline trusts your digest to decide whether an artefact changed, the digest has crossed a trust boundary and needs collision resistance.
  • Hashing is already negligible. If the build profile shows hashing under one percent of wall-clock time, switching algorithms trades a real invariant for an unmeasurable win.

Conversely, xxHash is the better default when the build is large, the digest never leaves your own pipeline, and integrity metadata is generated by a separate pass over the emitted bytes. Whatever you pick, pin it: changing hashFunction rewrites every filename in the build, so treat it like any other hash-affecting change and verify against deterministic build outputs before shipping it.

Frequently Asked Questions

Will switching to xxhash64 change every asset filename?

Yes, exactly once. Every fingerprint is recomputed with a different function, so the first build after the change invalidates the whole asset set at the edge. Deploy the assets before the HTML that references them, exactly as you would for any other full-set fingerprint change, and let the old objects expire with their TTL rather than purging them.

Is xxHash deterministic across platforms and Node versions?

Yes. xxHash64 is fully specified and endian-independent, and Webpack’s implementation is a WebAssembly module rather than a native binding, so a Linux CI runner and an ARM developer laptop produce identical digests for identical bytes. Any difference you observe comes from the input — line endings, injected timestamps, plugin ordering — not from the hash.

Does a faster hash make my assets less cacheable in any way?

No. The CDN and the browser treat the fingerprint as an opaque path segment; nothing at the edge inspects or recomputes it. Cacheability is decided by the Cache-Control header and by whether the URL changed, both of which are identical regardless of which function produced the characters. The only edge-visible consequence of switching algorithms is the one-off invalidation of the entire asset set on the first build after the change.

Can I use xxHash for the build cache but SHA-256 for filenames?

Not through a single Webpack option: output.hashFunction applies to everything Webpack hashes. If you need that split, leave hashFunction alone and drive filenames from a plugin that computes its own digest, or accept the simpler arrangement of a fast digest for filenames plus an independent SHA-384 pass for integrity metadata.