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.

How a content change propagates through hashed names Changing the Rust source changes the module bytes, so its hash and name change. The glue that references it is updated and gets a new hash, the entry script that imports the glue changes too, and the revalidated HTML points at the new entry script. Unchanged files keep their names and stay cached. module bytes change app_bg-3f9a1c → 9d40b7 glue updated app-71c2 → app-e8a0 entry script updated index-0b13 → index-5f62 HTML revalidated points at index-5f62 Files whose content did not change keep their names and remain cached with their compiled code.

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.

Naming schemes compared Fixed names, version-path names and content-hash names compared on whether they can be cached immutably, whether unchanged modules stay cached across releases, and the risk of mismatched glue and module. scheme immutable survives release mismatch risk fixed name (app.wasm) no only by revalidation high version path (/v2.4/app.wasm) yes no, new path each release low content hash (app-3f9a1c.wasm) yes yes low if all hashed

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.url inside 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.

Module downloads per returning user over 17 releases How many times a returning user downloaded the 2.4 MB module across a month of releases, under three naming schemes. module downloads over 17 releases fixed name, no-cache 17 downloads version path per release 17 downloads content hash 6 downloads

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.

← Back to Module Caching & Startup Performance