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 web or an Emscripten EXPORT_ES6 build 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.

Picking a .wasm strategy for a bundle For an application bundle, emit the .wasm as a hashed asset unless it is a few kilobytes, in which case inlining saves a request. For a library bundle, preserve the import.meta.url reference so the consumer's bundler handles the file. Who consumes this bundle's output? a page (application) Emit a hashed asset streaming compile, long-term cache tiny module, under ~8 KB Inline as base64 saves one request another bundler (library) Preserve the URL reference let the app's bundler decide

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.

Rollup and esbuild options for .wasm, compared How each bundler handles emitting, inlining and URL-reference rewriting for WebAssembly files, and whether streaming compilation survives the chosen strategy. capability Rollup esbuild streaming kept emit hashed asset plugin-wasm or meta-assets file loader yes inline as base64 plugin-wasm maxFileSize binary loader no rewrite import.meta.url refs import-meta-assets custom plugin yes library output untouched default external: *.wasm yes

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.wasm and gets a 404. The glue’s import.meta.url reference was not rewritten and the file was not copied. Copy it next to the bundle or use a rewriting plugin.
  • WebAssembly.instantiateStreaming fails with a MIME error. The server does not send application/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.

Time until a 410 KB module is ready, by bundling strategy Measured on a mid-range phone over a throttled 4G profile. Emitting the module as a hashed asset allows streaming compilation; inlining it as base64 delays compilation until the whole JavaScript bundle has arrived and parsed. ms from navigation to compiled module emitted asset, streaming 610 ms emitted, ArrayBuffer compile 720 ms inlined base64 950 ms

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.

← Back to ESM Bindings & Module Generation