Loading WebAssembly Modules in esbuild

esbuild has no built-in .wasm loader that instantiates a module for you — it can embed the bytes or emit a file, but turning those bytes into a callable WebAssembly.Instance is glue you supply. This guide shows both the simple file-loader path and an onLoad plugin that emits fetch-and-instantiate glue automatically. It is a concrete application of the onLoad hook from esbuild plugin patterns, within the wider esbuild & Turbopack Workflows.

The gap exists because a .wasm file is not source code that esbuild can parse and rewrite the way it does JavaScript, TypeScript, or CSS. It is an opaque binary that only becomes useful after a two-stage runtime handshake: the bytes are compiled into a WebAssembly.Module, then that module is instantiated against an imports object to produce a WebAssembly.Instance whose exports are the callable functions. esbuild is a bundler, not a runtime, so it deliberately stops at the point of moving bytes around and leaves the compile-plus-instantiate handshake to code that runs in the browser or in Node. Webpack hides this behind its asyncWebAssembly experiment and Vite hides it behind vite-plugin-wasm; esbuild’s philosophy is to give you the two byte-moving primitives (binary and file) and let you write the twelve lines of glue that suit your target.

Getting this wrong fails in two distinct ways, and they surface at different times. Forget to map the .wasm extension to any loader and the build stops immediately with No loader is configured for ".wasm" files — a compile-time failure that is annoying but obvious. Map it correctly but let the server mislabel the response MIME type and the build stays green while the runtime throws Incorrect response MIME type, often only in production behind a CDN that has never heard of application/wasm. The whole point of choosing between the two loaders and deciding who instantiates is to make both of those failure modes impossible rather than to rediscover them per project. This page sits in the plumbing layer of the build pipeline: after resolution, before your app code can call a WebAssembly export.

Two paths from a .wasm import to a callable instance The file loader emits the .wasm as a separate asset and gives you its URL to fetch and instantiate at runtime, while an onLoad plugin generates glue that fetches and instantiates the module and exports its instance directly. esbuild moves the bytes; you write the instantiate glue import 'add.wasm' file loader → URLyou fetch + instantiate onLoad glue → instanceimport exports directly
Figure: the file loader hands you a URL; the plugin hands you a ready instance — pick by how much control you want.

Prerequisites & reproducible setup

# esbuild 0.25.x, Node 18+, wat2wasm from wabt (or any prebuilt .wasm)
mkdir wasm-demo && cd wasm-demo && npm init -y
npm install --save-dev esbuild@0.25.0
cat > add.wat <<'WAT'
(module (func (export "add") (param i32 i32) (result i32)
  local.get 0 local.get 1 i32.add))
WAT
wat2wasm add.wat -o add.wasm
echo "import wasmUrl from './add.wasm'; console.log(wasmUrl);" > entry.ts

Building entry.ts with no loader configured for .wasm fails with No loader is configured for ".wasm" files — esbuild needs to be told how to treat the extension.

The .wat text format is the human-readable side of WebAssembly; wat2wasm from the WABT toolkit assembles it into the binary .wasm that ships. The module above exports a single add(i32, i32) -> i32 with no imports, which makes it the ideal fixture: it is self-contained, so an empty imports object {} is enough to instantiate it, and it is a handful of bytes, so you can hold the whole thing in your head while debugging the plumbing. Keep a trivial module like this in the repo even after you switch to a real payload — when a build breaks, swapping the real .wasm for add.wasm tells you in one build whether the fault is in your glue or in the module itself. If you do not have WABT installed, any prebuilt .wasm works; the loader machinery never inspects the contents.

esbuild has no default .wasm loader An unconfigured .wasm import errors with No loader is configured for .wasm files; you must map the extension to the file or binary loader, or intercept it with an onLoad plugin. No loader for .wasmextension unmapped map to file / binary / pluginthen it bundles the extension needs an explicit loader
Figure: the error is a missing loader mapping, not a WebAssembly problem — decide the loader first.

Diagnosis workflow

  1. Decide embed vs external. The binary loader inlines the bytes as a Uint8Array (good for tiny modules, no extra request); the file loader emits a separate .wasm asset and gives you its URL (good for larger modules and streaming). The mechanism behind binary is base64: esbuild encodes the bytes as a string literal in the JS output and decodes it back to a Uint8Array at load time, which inflates the payload by roughly a third and forces the whole thing through the JavaScript parser. That decode cost is invisible for a 400-byte helper and painful for a 4 MB codec, which is the real dividing line between the two loaders rather than any hard size limit.
  2. Decide who instantiates. With file/binary you write WebAssembly.instantiate(...); a plugin can emit that glue so the import returns the ready instance. The trade-off is where the async boundary lives: hand-written instantiation gives you a Promise you can place exactly where you want it (behind a lazy route, inside a Web Worker, guarded by a feature check), whereas plugin-emitted glue that uses top-level await moves the wait to module-evaluation time, so importing the module blocks until the instance is ready. For a codec that every page needs, the plugin’s eagerness is a feature; for one that only a rarely-visited settings panel needs, hand-written glue behind a dynamic import() keeps it off the critical path.
  3. Confirm the server serves application/wasm. WebAssembly.instantiateStreaming requires that MIME type; a wrong type silently falls back to the slower arrayBuffer path or throws. Streaming instantiation is worth the fuss because it compiles the module while the bytes are still downloading rather than waiting for the full ArrayBuffer — for a multi-megabyte module that overlap can halve time-to-first-call. The browser enforces the MIME check strictly precisely so that it can trust the stream is really WebAssembly before it starts compiling, which is why a text/plain or application/octet-stream response is refused outright rather than sniffed.
binary loader versus file loader The binary loader inlines the wasm bytes into the JS bundle as a Uint8Array with no extra network request, best for tiny modules, while the file loader emits a separate .wasm asset and returns its URL, best for larger modules that benefit from streaming instantiation. inline the bytes, or emit an asset binary loaderUint8Array inlinedtiny modules, no request file loaderseparate .wasm + URLlarger, streamable
Figure: size decides the loader — inline the small ones, stream the big ones.

Annotated solution config

The plugin below is deliberately two-layered. Its onResolve claims every .wasm import into a private namespace so no other plugin or default loader touches it, and its onLoad returns JavaScript, not WebAssembly — the JS it returns contains its own import of the real .wasm path, and that inner import is what the file loader handles. This split is the crux of the pattern: the plugin never reads or fetches the binary itself, it only writes the glue and lets esbuild’s ordinary asset pipeline emit the file and rewrite the inner import into a hashed URL.

// build.mjs — esbuild 0.25.x, Node 18+
import * as esbuild from 'esbuild';

// A plugin that turns `import init from './x.wasm'` into ready-to-call glue.
const wasmPlugin = {
  name: 'wasm',
  setup(build) {
    // Resolve .wasm imports into a dedicated namespace, remembering the path.
    build.onResolve({ filter: /\.wasm$/ }, (args) => ({
      path: new URL(args.path, `file://${args.resolveDir}/`).pathname,
      namespace: 'wasm-glue',
    }));
    // Emit JS glue that reads the file at build time and instantiates at runtime.
    build.onLoad({ filter: /.*/, namespace: 'wasm-glue' }, async (args) => ({
      contents: `
        import wasmUrl from ${JSON.stringify(args.path)};
        const res = await fetch(wasmUrl);
        const { instance } = await WebAssembly.instantiateStreaming(res, {});
        export default instance.exports;
      `,
      loader: 'js',
      resolveDir: build.initialOptions.absWorkingDir || process.cwd(),
    }));
  },
};

await esbuild.build({
  entryPoints: ['app.ts'],
  bundle: true,
  format: 'esm',
  target: 'es2022',            // top-level await needs es2022+
  outfile: 'dist/out.js',
  loader: { '.wasm': 'file' }, // the inner import from the glue uses file loader
  plugins: [wasmPlugin],
});
Two layers: plugin emits glue, file loader emits the asset The plugin's onLoad emits JS glue whose own inner import of the wasm path is handled by the file loader, which emits the .wasm asset and provides its URL; the glue then fetches that URL and instantiates the module with top-level await. plugin onLoademits fetch glue file loaderemits .wasm + URL instance.exportstop-level await the glue's inner import rides the file loader
Figure: the plugin and the file loader cooperate — one writes the glue, the other emits the asset the glue fetches.

How it works under the hood

esbuild runs plugin hooks in registration order, but the ordering that matters here is the phase ordering: for any given import path, every onResolve callback runs first and the first one to return a non-null result wins and fixes the path plus namespace; only then does esbuild look for an onLoad whose filter and namespace both match. Because our onResolve stamps the import with namespace: 'wasm-glue' and our onLoad filters on that same namespace, the two are guaranteed to pair up, and the broad filter: /.*/ on the onLoad is safe — it can never fire for anything outside the namespace we control.

The new URL(args.path, ...).pathname call in onResolve is doing real work, not decoration. onResolve receives the import specifier as written (./add.wasm) plus the resolveDir of the importing file; joining them through the URL constructor produces an absolute path that survives regardless of which subdirectory the import came from. Skip that normalization and a .wasm imported from two different directories collides or fails to resolve. The returned contents string is then handed back into esbuild’s own bundler as if it were a real source file, which is why its inner import wasmUrl from '<abs path>' gets resolved, hashed, and emitted by the file loader exactly like any hand-written asset import.

Content hashing happens at the file-loader stage. esbuild hashes the .wasm bytes and folds that hash into the emitted filename (when you enable [hash] in assetNames), so a byte-identical module keeps its URL across builds and a changed module gets a fresh one — the cache-busting is automatic and content-addressed rather than time-based. The glue’s fetch(wasmUrl) therefore always points at the hashed name esbuild chose, and you never hand-write the URL.

Verification

# esbuild 0.25.x — build, then serve so the .wasm gets the right MIME type.
node build.mjs
npx serve dist            # servers should send application/wasm for .wasm
# In the browser console: (await import('./out.js')).default.add(2, 3) === 5

A working setup returns 5 from the exported add. If instantiation throws Incorrect response MIME type, the static server is not sending application/wasm for .wasm and streaming instantiation refuses to run.

Do not trust the console result alone — confirm the shape of the output too. Open the Network tab and check that the .wasm request returns 200 with a Content-Type of application/wasm; a 304 or a text/html fallback (the classic single-page-app rewrite catching an unknown extension) both explain a runtime failure that the build never warned about. Then inspect the emitted dist directory: you should see one hashed .wasm file alongside out.js, and grepping out.js for the hash should turn up exactly the URL the glue fetches. If the .wasm is missing from dist, the file loader never ran and the import is probably still being caught by another loader mapping.

For a fully deterministic check that does not depend on a browser at all, drive the module from Node with an explicit fs read, which sidesteps the whole MIME question:

// verify.mjs — esbuild 0.25.x, Node 18+ — build-free smoke test of the raw module
import { readFile } from 'node:fs/promises';
const bytes = await readFile(new URL('./add.wasm', import.meta.url));
const { instance } = await WebAssembly.instantiate(bytes, {});
console.assert(instance.exports.add(2, 3) === 5, 'add() must return 5');
console.log('ok:', instance.exports.add(2, 3));

Running node verify.mjs proves the .wasm itself is sound before you blame the bundler or the server, which is the fastest way to bisect a broken pipeline into “bad module” versus “bad glue”.

Streaming instantiation depends on the MIME type When the server sends application/wasm the streaming instantiation succeeds and the exported function returns its result, but a wrong MIME type makes instantiateStreaming throw an incorrect response MIME type error. application/wasmadd(2,3) → 5 wrong MIME typeinstantiateStreaming throws the server, not esbuild, controls the MIME type
Figure: a green build can still fail at runtime if the server mislabels the .wasm — check the response headers.

Gotchas & edge cases

Four WebAssembly loading edge cases Top-level await needs an es2022 target and ESM format; instantiateStreaming needs the application/wasm MIME type or an arrayBuffer fallback; a module importing host functions needs a non-empty imports object; and Node cannot always fetch a file URL so read with fs instead. top-level await target→ target es2022 + ESM wrong MIME type→ arrayBuffer fallback host imports needed→ pass a non-empty imports obj Node file:// fetch→ read with fs on Node target
Figure: the MIME-type case bites in production behind a CDN even when local serve worked.
  • Top-level await target. The glue uses top-level await, which needs target: 'es2022' or later and an ESM format. Symptom: the build fails with Top-level await is currently not supported with the "cjs" output format or a target-too-low error. Root cause: top-level await only became legal in ES2022 modules, and it is meaningless in CommonJS because require is synchronous. Fix: set format: 'esm' and target: 'es2022'. Confirm by grepping the output for await WebAssembly at the module top level — if esbuild kept it there rather than erroring, the target accepted it.
  • MIME type. instantiateStreaming requires application/wasm. Symptom: TypeError: Incorrect response MIME type at runtime, typically only in production. Root cause: the static host or CDN in front of the assets does not map .wasm to application/wasm and returns application/octet-stream or a SPA HTML fallback. Fix: configure the server’s MIME table, or make the glue resilient by falling back to WebAssembly.instantiate(await res.arrayBuffer()), which skips the streaming compile and therefore skips the MIME check. Confirm from the Network tab’s Content-Type header, not from the source of the file on disk.
  • Imports object. A module that imports host functions (memory, env) needs a non-empty imports object passed to instantiate. Symptom: LinkError: import object field 'env' is not an Object or a missing-function link error. Root cause: the empty {} in the glue satisfies only self-contained modules; anything compiled from C, Rust with wasm-bindgen off, or code touching linear memory declares imports the host must supply. Fix: build the imports object to match the module’s import section (inspect it with wasm-objdump -x from WABT), or use the toolchain’s generated JS shim instead of this bare glue. Confirm the link succeeds by checking Object.keys(instance.exports) is populated.
  • Node vs browser fetch. In Node, fetch of a file:// URL is not universally supported across versions. Symptom: fetch failed or ERR_UNSUPPORTED_ESM_URL_SCHEME when the same glue that works in the browser runs under Node. Root cause: fetch is specified for HTTP(S), and file:// support is inconsistent. Fix: on a Node target, read the bytes with fs and call WebAssembly.instantiate(bytes, imports) as in the verification script above. Confirm by running the module under node directly rather than only in a browser.

Performance considerations

The dominant cost in a WebAssembly load is compilation, and it scales with module size, not with how clever the glue is. instantiateStreaming is the fastest path because it overlaps download and compile; the arrayBuffer fallback serializes them. Both are still far cheaper than base64-inlining a large module through the binary loader, where the bytes pay a JavaScript-parse tax on every page load in addition to the WebAssembly compile. As a rough rule, reach for binary only under a few kilobytes — a bitmask table, a tiny hashing routine — and file for anything a user would notice downloading. If several routes share one large module, keep it external and let the browser’s HTTP cache and the content-hash filename do the deduplication; inlining would copy the bytes into every entry point that imports it.

The second lever is when instantiation runs. Because the plugin’s top-level await blocks module evaluation, a module that reaches the codec transitively will pay the instantiate wait during startup even if the code path is never taken. Where that matters, drop the plugin for that import and instantiate behind a lazy import() so the compile happens on first use, off the initial critical path. For modules that must be ready synchronously later, WebAssembly.compileStreaming once at startup and cache the resulting Module, then new WebAssembly.Instance(module, imports) synchronously per use — compilation is the expensive half and it is shareable, instantiation is cheap and per-use.

When to reach for the plugin versus the plain loader

Prefer the plain file loader plus hand-written instantiation when there is exactly one .wasm import, when you need the async boundary in a specific place, or when the module needs a real imports object — the explicit WebAssembly.instantiate call is clearer than glue you have to read through a plugin to understand. Reach for the onLoad plugin when many modules import many .wasm files and you want a uniform import init from './x.wasm' ergonomic across the codebase, so no one has to remember to fetch and instantiate by hand. The plugin trades a little indirection for consistency; on a single-module project that indirection is pure overhead.

Compared with the alternatives, esbuild’s stance is the least magical. Vite’s vite-plugin-wasm and webpack’s asyncWebAssembly experiment both make import of a .wasm return the instantiated exports for you, which is convenient until you need to control the imports object or the instantiation timing and have to fight the framework’s assumptions. The plugin in this guide reproduces that convenience in about a dozen lines you own outright, so when a module suddenly needs a non-empty imports object you edit the glue rather than swapping frameworks. That ownership is the reason to write it yourself even though it is more code than flipping a flag.