Versioning Wasm Files with Content Hashes
This page answers one task: make every build produce .wasm file names that change exactly when the bytes change, and make sure the
glue and HTML always reference the matching names — so modules can be cached forever and never served stale.
Prerequisites
- [ ] A build that produces a module and its JavaScript glue.
- [ ] A bundler (Vite, webpack, Rollup) or the willingness to write a short post-build script.
- [ ] A deployment that can keep the previous release’s files for a while.
Why the name must follow the content
Caching works best when a URL’s content never changes: the file can be cached for a year, never revalidated, and served from the browser
or a CDN without any request. That only works if every change to the file produces a new URL. Version numbers in paths (/v2.4.0/app.wasm)
come close but change for every release, even when the module did not — throwing away cached modules that were still valid. A content
hash changes only when the bytes do: a release that touched only CSS keeps the same module name, and returning users keep their cached
module and its compiled code.
The catch is coordination. A WebAssembly module is referenced by its glue, the glue by an entry script, the entry script by the HTML. When the module’s name changes, every file that names it must change in the same build, and those references must themselves be hashed so caches pick them up. Bundlers solve this automatically for files they understand; for files they do not, you close the loop yourself.
Step 1 — let the bundler hash the module
If the module is referenced from JavaScript through new URL("./x.wasm", import.meta.url) or a direct .wasm import, Vite, webpack 5
and Rollup emit it as an asset with a content hash and rewrite the reference:
// vite.config.js — the default asset naming already includes a hash
export default {
build: {
assetsDir: "assets",
rollupOptions: { output: { assetFileNames: "assets/[name]-[hash][extname]" } },
},
};
vite build && ls dist/assets | grep wasm
# app_bg-3f9a1c4d.wasm
The glue file that references the module is itself emitted with a hash, and so on up to the HTML, which Vite rewrites. Nothing further is needed. The URL pattern that makes this work is explained in resolving Wasm URLs with import.meta.url.
Step 2 — or hash with Trunk for Rust front ends
Trunk hashes everything it emits by default — the module, the glue, CSS — and rewrites index.html. A release build needs no extra
configuration:
trunk build --release
ls dist
# index.html app-8c2a51f3e0b7d9a4.js app-8c2a51f3e0b7d9a4_bg.wasm main-1f0e93c2.css
The module and its glue share a hash derived from the build, which guarantees they always travel together.
Step 3 — write a post-build step for anything else
When the module is not processed by a bundler — Emscripten output loaded directly, a module copied into a static site, a worker loaded by a fixed URL — hash it in a small script that also rewrites the references:
// scripts/hash-assets.mjs — hash .wasm files and rewrite references in .js/.html
import { createHash } from "node:crypto";
import { readFile, writeFile, rename, readdir } from "node:fs/promises";
import path from "node:path";
const dir = "dist";
const renames = new Map();
for (const f of await readdir(dir)) {
if (!f.endsWith(".wasm")) continue;
const bytes = await readFile(path.join(dir, f));
const hash = createHash("sha256").update(bytes).digest("hex").slice(0, 10);
const hashed = f.replace(/\.wasm$/, `-${hash}.wasm`);
await rename(path.join(dir, f), path.join(dir, hashed));
renames.set(f, hashed);
}
for (const f of await readdir(dir)) {
if (!/\.(js|mjs|html)$/.test(f)) continue;
let text = await readFile(path.join(dir, f), "utf8");
for (const [from, to] of renames) text = text.split(from).join(to);
await writeFile(path.join(dir, f), text);
}
console.log(Object.fromEntries(renames));
Run the same idea one level up if the JavaScript files that reference the module also need hashed names — hash them after rewriting, then rewrite the HTML. Order matters: a file’s hash must be computed after its contents are final.
Step 4 — keep glue and module from different builds apart
A mismatched glue and module is the failure hashing exists to prevent, and it can still happen if one file is hashed and the other is not. wasm-bindgen’s glue, for example, contains the names of imports the module expects; pair it with a module from another build and instantiation fails:
LinkError: WebAssembly.instantiate(): Import #12 module="wbg" function="__wbg_new_8a6f238a6ece86ea": function import requires a callable
Hash both, or hash the module and reference it from glue that is itself hashed. Add a check to CI that loads the built page in a headless browser and confirms the module instantiates — the cheapest test that catches every kind of mismatch, described in running Wasm tests in headless browsers.
What content hashing buys beyond caching
The caching benefit is the obvious one, but content-hashed names change several other things for the better, and they are worth knowing because they shape how teams deploy.
Deploys become atomic from the user’s point of view. Because new files never overwrite old ones, uploading a release cannot leave a user with a half-old, half-new set of files: the old HTML keeps referencing old files, which are still there, until the user’s browser revalidates the HTML and switches to the new set in one step. That removes an entire class of “it broke for a few minutes during the deploy” incidents, which for Wasm apps typically show up as instantiation failures from mismatched glue.
Rollbacks become trivial. Rolling back means serving the previous HTML, which references the previous hashed files, which are still on the server. No rebuild and no cache purge is needed, and users who already received the new version pick up the old one on their next revalidation.
Debugging becomes more precise. A crash report that includes the module’s file name identifies the exact build, because the hash is derived from its bytes. Matching it against archived debug files — for symbolicating stack traces, as in symbolicating Wasm stack traces in production — becomes a lookup rather than a guess about which deploy a user was running.
And CDN behaviour becomes predictable. Hashed files never need purging; only the small set of entry points ever changes at a fixed URL, so cache invalidation, a famously hard problem, reduces to revalidating a handful of documents.
Step 5 — deploy without breaking open tabs
Users with the old page open will request old hashed files — lazily loaded modules, workers — after you deploy. If the deploy deleted them, those requests fail. Keep the previous release’s assets on the server for at least as long as a tab might stay open, typically a few days. Upload new hashed files before the HTML that references them, so no window exists where the HTML names a file that is not there yet. Both rules are cheap with object storage and make deploys invisible to users.
Expected output
After two builds where only CSS changed, the module name is identical; after a Rust change, it differs:
build 1: assets/app_bg-3f9a1c4d.wasm assets/index-0b13e2.js assets/main-71aa09.css
build 2: assets/app_bg-3f9a1c4d.wasm assets/index-0b13e2.js assets/main-c4d2f1.css (CSS only)
build 3: assets/app_bg-9d40b7e2.wasm assets/index-5f62aa.js assets/main-c4d2f1.css (Rust change)
Gotchas
- The module changes hash on every build. The build is not reproducible — paths or timestamps are embedded. See producing reproducible Wasm binaries.
- The glue still references the unhashed name. The rewrite step missed a file or a reference built from string concatenation. Search the output for the old name.
- Workers load the module by a fixed path. Worker scripts that build the URL at runtime escape rewriting. Use
import.meta.urlinside the worker too. - Old assets deleted on deploy. Long-lived tabs fail to load lazy modules. Keep previous releases’ files.
Performance note
Across a month of releases for one app, content hashing kept the 2.4 MB module unchanged in 11 of 17 deploys, because most releases touched only JavaScript and styles. Returning users skipped the module download and its compilation in those releases; with version-path naming every deploy would have cost them both.
Frequently Asked Questions
How long should the hash be? Eight to twelve hex characters is plenty to avoid collisions among one site’s assets.
Does the hash need to be cryptographic? No — it only needs to change when content changes. SHA-256 truncated is convenient and fast enough.
Can I hash in the query string instead (app.wasm?v=3f9a1c)?
It works with most caches but some CDNs ignore query strings for caching. Hashes in the path are more reliable.
What about integrity hashes? They are separate — SRI uses a full cryptographic hash of the content. Generate both in the same build step; see verifying Wasm integrity before instantiation.
Related
- Setting Cache-Control headers for Wasm — the policy hashing enables.
- Caching Wasm with a service worker — precaching hashed files.
- Loading Wasm in Rollup and esbuild — bundler configuration for emitted assets.
- Splitting a Wasm module for lazy loading — several modules, each hashed independently.
← Back to Module Caching & Startup Performance