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.
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
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.
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.
Related
- MD5 vs SHA-256 for assets — parent guide covering algorithm selection, bundler defaults, and the full configuration reference
- Safely truncating content hash length — birthday-bound collision probabilities at 8, 12, and 16 hex characters
- Subresource integrity validation — how browsers verify integrity metadata and which algorithms they accept
- Deterministic build outputs — keeping digests stable across machines and CI runs after an algorithm change