Loading Wasm in Rollup and esbuild
This page answers one task: get a .wasm file through a Rollup or esbuild build so that the output loads it
correctly — either as a separate, hashed asset or inlined into JavaScript — and pick the right strategy for a
library versus an application.
Prerequisites
- [ ] Node 20+ and either Rollup 4 or esbuild 0.23+.
- [ ] A module and its glue, for example the output of
wasm-pack build --target webor an EmscriptenEXPORT_ES6build from emitting ES modules from Emscripten. - [ ] A decision about the consumer: will people import your build output (a library) or load it in a page (an application)?
Three strategies, one question
Every bundler offers some combination of the same three strategies for a binary file. It can emit the file as a separate asset and give your code its URL. It can inline the file as base64 inside the JavaScript. Or it can leave it alone, keeping the reference as written and expecting something downstream — another bundler, or the server layout — to make the URL work.
The question that decides between them is who consumes the output. An application bundle is the end of the line:
emitting the asset with a content hash is almost always right. A library bundle is consumed by someone else’s
bundler: leaving the new URL(..., import.meta.url) reference intact lets that bundler handle the file in the
context of the final application, which is better than baking in a decision early.
Step 1 — Rollup: emit the module as an asset
Rollup has no built-in handling for .wasm. For code that already uses the URL pattern — wasm-bindgen’s web
target and Emscripten’s EXPORT_ES6 output both do — the simplest correct setup is a plugin that rewrites
new URL("x.wasm", import.meta.url) to point at an emitted, hashed copy:
// rollup.config.mjs
import { importMetaAssets } from "@web/rollup-plugin-import-meta-assets";
import resolve from "@rollup/plugin-node-resolve";
export default {
input: "src/main.js",
output: { dir: "dist", format: "es", assetFileNames: "assets/[name]-[hash][extname]" },
plugins: [resolve(), importMetaAssets()],
};
// src/main.js — wasm-pack --target web output, imported as usual
import init, { checksum } from "../pkg/hasher.js";
await init(); // fetches the .wasm via import.meta.url
console.log(checksum(new Uint8Array([1, 2, 3])));
The plugin follows the URL inside pkg/hasher.js, copies hasher_bg.wasm into dist/assets/ with a hash in its
name, and rewrites the reference. At runtime the glue streams the file with instantiateStreaming, so compilation
overlaps the download.
Step 2 — Rollup: import a .wasm file directly
For a hand-written or minimal module with no glue, @rollup/plugin-wasm lets you import the file and receive a
function that instantiates it:
// rollup.config.mjs
import { wasm } from "@rollup/plugin-wasm";
export default {
input: "src/main.js",
output: { dir: "dist", format: "es" },
plugins: [wasm({ maxFileSize: 8192, fileName: "assets/[name]-[hash][extname]" })],
};
import instantiate from "./math.wasm";
const { instance } = await instantiate({ env: { log: console.log } });
console.log(instance.exports.add(2, 3));
maxFileSize sets the inlining threshold: modules smaller than 8 KB are embedded as base64, larger ones are
emitted and fetched. The reasoning behind that number is in
inlining small Wasm modules as base64.
Checking what the bundle actually did
Whichever plugin you use, verify the result rather than trusting the configuration. Three quick checks catch
nearly every mistake. First, list the output directory and confirm the .wasm file is there with a hashed name —
if it is missing, the reference was not followed. Second, search the emitted JavaScript for the file name; the
reference should point at the hashed copy, not the original name from your source tree. Third, load the page with
the Network panel open and confirm the binary is requested once, with the right content type, and that no
second request for an unhashed name appears and fails.
ls dist/assets/ | grep wasm
grep -o '[a-z_]*-[A-Za-z0-9]*\.wasm' dist/*.js | sort -u
A surprisingly common failure is two copies of the binary in the output: one emitted by a Wasm plugin because the
file was imported, and another copied by an assets plugin because a URL also referenced it. The page works, but
users download the module twice on a cold cache. The grep above shows both names if that has happened, and the
fix is to choose one mechanism per file.
Step 3 — esbuild: choose a loader
esbuild handles binary files through loaders. The file loader copies the file to the output directory and
replaces the import with its URL; the binary loader inlines it as a Uint8Array.
// build.mjs
import * as esbuild from "esbuild";
await esbuild.build({
entryPoints: ["src/main.js"],
bundle: true,
format: "esm",
outdir: "dist",
assetNames: "assets/[name]-[hash]",
loader: { ".wasm": "file" },
});
// src/main.js
import wasmUrl from "./math.wasm"; // a string URL with the file loader
const { instance } = await WebAssembly.instantiateStreaming(fetch(wasmUrl), { env: {} });
With loader: { ".wasm": "binary" } the same import yields the bytes directly, and you would call
WebAssembly.instantiate(bytes, imports) instead — no fetch, no streaming.
Step 4 — esbuild: handle new URL(..., import.meta.url)
esbuild does not rewrite new URL("x.wasm", import.meta.url) references, which means wasm-pack’s web target and
Emscripten’s ES6 output keep pointing at a file esbuild never copied. Two fixes work. For an application, copy the
.wasm next to the bundle as a post-build step so the relative URL resolves:
import { copyFile } from "node:fs/promises";
await esbuild.build({ /* … as above, without the wasm loader … */ });
await copyFile("pkg/hasher_bg.wasm", "dist/hasher_bg.wasm");
For something more robust, use a small plugin that intercepts the glue file and replaces the URL expression with
an import the file loader understands:
const wasmUrlPlugin = {
name: "wasm-url",
setup(build) {
build.onLoad({ filter: /pkg[\\/][^\\/]+\.js$/ }, async (args) => {
const fs = await import("node:fs/promises");
let src = await fs.readFile(args.path, "utf8");
src = src.replace(
/new URL\(['"]([^'"]+\.wasm)['"],\s*import\.meta\.url\)/g,
(_, f) => `new URL(__wasm_${f.replace(/\W/g, "_")}, location.href)`
).replace(/^/, (m) => {
const files = [...src.matchAll(/['"]([^'"]+\.wasm)['"]/g)].map((x) => x[1]);
return files.map((f) => `import __wasm_${f.replace(/\W/g, "_")} from "./${f}";\n`).join("") + m;
});
return { contents: src, loader: "js", resolveDir: (await import("node:path")).dirname(args.path) };
});
},
};
That is more machinery than the Rollup setup, and it is a fair summary of the two tools: esbuild is faster and simpler for plain JavaScript, Rollup’s plugin ecosystem handles asset references more completely.
Step 5 — library builds: leave the reference alone
When the output is a library that others bundle, do none of the above. Mark the .wasm as external so neither
tool touches it, and ship the file alongside the JavaScript:
// esbuild
await esbuild.build({ entryPoints: ["src/index.js"], bundle: true, format: "esm",
outdir: "dist", external: ["*.wasm"] });
// rollup — no wasm plugin at all; copy the file in package.json "files"
export default { input: "src/index.js", output: { dir: "dist", format: "es" } };
Then make sure package.json lists the .wasm file in files so npm publishes it — the most common reason a
Wasm library works locally and fails for every user. Packaging is covered fully in
publishing a Wasm package to npm.
Expected output
An esbuild application build with the file loader:
dist/main.js 1.6kb
dist/assets/math-QX7T2FZK.wasm 612b
⚡ Done in 9ms
In the browser, the Network panel shows a request for the hashed .wasm with application/wasm as its content
type; DevTools’ Sources panel lists the module under wasm://.
Gotchas
No loader is configured for ".wasm" files. esbuild needs an explicit loader. Add".wasm": "file"or"binary".- The page fetches
/hasher_bg.wasmand gets a 404. The glue’simport.meta.urlreference was not rewritten and the file was not copied. Copy it next to the bundle or use a rewriting plugin. WebAssembly.instantiateStreamingfails with a MIME error. The server does not sendapplication/wasm. Fix the server, as in serving Wasm with the correct MIME type locally.- Inlined modules bloat the JavaScript bundle. Base64 adds a third to the size and blocks streaming. Keep the inlining threshold small.
Performance note
For a 410 KB module, emitting it as a separate asset let the browser compile it while still downloading, and the module was ready 120 ms after the response started. The same module inlined as base64 grew to 547 KB inside the JavaScript bundle, and compilation could not begin until the entire bundle had downloaded and parsed — about 340 ms later on a mid-range phone. Inline only modules small enough that the request costs more than the bytes.
Frequently Asked Questions
Which is faster to build with, Rollup or esbuild? esbuild, typically by an order of magnitude on large codebases. For a library whose output is mostly glue and a binary, the difference is a second or two either way.
Does esbuild’s binary loader work in Node? Yes; the bytes are embedded in the bundle, so there is no file to locate at runtime. That makes it a reasonable choice for single-file CLI tools.
Can I use these with TypeScript?
Add a declaration for *.wasm imports — declare module "*.wasm" { const url: string; export default url; } for
the file loader — so the compiler accepts the import.
Related
- Bundling Wasm with webpack 5 — the same decisions in webpack.
- Bundling Wasm ESM with Vite — Vite uses Rollup for production builds.
- Importing Wasm with the ESM integration proposal — what these plugins emulate.
- Versioning Wasm files with content hashes — why the hashed asset names matter.
← Back to ESM Bindings & Module Generation