Running Wasm in Bun

This page answers one task: a project uses Bun as its runtime, package manager or bundler, and needs WebAssembly modules — its own, generated glue, or npm packages — to load and run correctly, with an eye on where Bun behaves differently from Node.

Prerequisites

  • [ ] Bun 1.1 or newer.
  • [ ] A .wasm module, a toolchain’s output, or an npm package that uses Wasm.

Bun’s approach to WebAssembly

Bun runs on JavaScriptCore, Safari’s engine, rather than V8. Its WebAssembly implementation is therefore JavaScriptCore’s — BBQ and OMG tiers instead of Liftoff and TurboFan — with the standard WebAssembly JavaScript API on top. Bun aims for Node compatibility, so most Node-style loaders work as is, and adds conveniences of its own: .wasm files can be imported directly, Bun.file() reads files lazily and fast, and the bundler understands .wasm imports. It also includes a WASI implementation, so a WASI module can run with a single command.

Because the engine differs, performance and edge-case behaviour can differ from Node. A module that relies on a feature JavaScriptCore has not shipped yet, or that is tuned for V8’s tiering, may behave differently. Testing in Bun is therefore not optional if you support it.

Wasm capabilities in Node and Bun Both runtimes implement the standard WebAssembly API. Bun allows direct .wasm imports and running WASI modules from the CLI without flags. Node needs an experimental flag for direct imports and uses node:wasi for WASI. The engines differ: V8 in Node, JavaScriptCore in Bun. capability Node.js Bun WebAssembly JS API yes yes import from .wasm experimental flag yes WASI preview 1 node:wasi module built in (bun run m.wasm) engine V8 (Liftoff/TurboFan) JavaScriptCore (BBQ/OMG) bundler handles .wasm n/a bun build

Step 1 — load a module the standard way

The portable loader from Node works in Bun unchanged:

import { readFile } from "node:fs/promises";

const bytes = await readFile(new URL("./math.wasm", import.meta.url));
const { instance } = await WebAssembly.instantiate(bytes, { env: { log: console.log } });
console.log(instance.exports.add(2, 3));

Bun’s own API reads files faster and returns an ArrayBuffer directly:

const bytes = await Bun.file(new URL("./math.wasm", import.meta.url)).arrayBuffer();

fetch with a file: URL also works, so the browser-style instantiateStreaming(fetch(url)) pattern runs in Bun as well — useful when the same loader must work in browsers.

Whichever reader you use, compile once per process and keep the WebAssembly.Module if you need more than one instance — for example one per worker — exactly as you would in Node.

Step 2 — import .wasm directly

import { add } from "./math.wasm";
console.log(add(2, 3));

Bun compiles and instantiates the module when it is imported. Like Deno’s implementation, this suits modules whose imports are satisfied by other modules or that have none. For modules that need a runtime-built import object, use the explicit loader from step 1. Direct imports also make the module part of the dependency graph, so bun build can see and bundle it without extra configuration.

Step 3 — run WASI modules

Bun runs WASI preview 1 command modules directly:

bun run ./wc.wasm input.txt

Programmatically, Bun supports Node’s node:wasi API, so code written for Node, as in running WASI modules in Node.js, generally runs as is. Coverage of WASI functions may differ from Node’s; test file-system operations your module relies on, especially directory listings and renames, which are the least commonly used parts of WASI preview 1.

Running a WASI tool in Bun Bun detects a WASI preview 1 module, provides its imports from the built-in WASI implementation, passes command-line arguments and the working directory, and runs _start, returning the tool's exit code. bun run tool.wasm CLI entry detect WASI imports preview 1 built-in WASI host args, env, files _start runs tool executes exit code back to the shell

Step 4 — use toolchain output and npm packages

wasm-bindgen’s web and bundler targets work in Bun; so does nodejs through Bun’s CommonJS support. Emscripten output built with -sENVIRONMENT=node or web generally runs. npm packages that ship WebAssembly — image codecs, parsers, compression libraries — install with bun install and load through their normal Node code paths.

When something fails, the cause is usually one of three things: a package that checks process.versions.node or typeof Bun and takes an unexpected branch; a native addon fallback that Bun cannot load (prefer the package’s Wasm build, often exposed through a wasm32 or -wasm variant); or a Wasm feature JavaScriptCore does not support yet. Running the package’s own test suite under bun test is a fast way to find out.

Step 5 — bundle for production

bun build resolves .wasm imports and copies the files into the output directory, rewriting references so the bundle can find them:

bun build ./src/index.ts --outdir dist --target bun

For --target browser, the .wasm file is emitted as an asset and loaded at runtime. bun build --compile produces a single executable; modules imported directly are embedded, while files read at runtime with Bun.file must be included explicitly or shipped alongside. Check the output directory to confirm the .wasm file is there and that the loader references it by a path that will exist after deployment.

Serving Wasm with Bun.serve

Bun is often used as an HTTP server, and two Wasm-related uses come up. The first is serving .wasm files to browsers: Bun.file() infers application/wasm from the extension, so new Response(Bun.file("./public/app.wasm")) sends the right Content-Type for streaming compilation, and Bun handles range requests and conditional requests for static files. Add long-lived Cache-Control headers for content-hashed file names, as for any static asset. The second is running Wasm inside request handlers — templating, validation, image processing. Instantiate the module once at startup, at module top level, and reuse the instance across requests; per-request instantiation is cheap from a compiled module but still measurable at high request rates. If handlers can run concurrently and the module keeps per-call state in linear memory, either make that state per-request inside the module, or use a small pool of instances and hand one to each request, returning it afterwards. CPU-heavy work belongs in a Worker, so one slow image does not delay every other request on the event loop.

Testing across runtimes

If a package supports Node, Deno and Bun, the cheapest insurance is running the same test suite in all three. Keep tests in plain ES modules using a runner each runtime supports — node:test style APIs run in Node and Bun, and Deno has its own Deno.test but can run node:test through compatibility — or use a small wrapper that maps to each runtime’s runner. In CI, a matrix of three jobs, each installing one runtime and running the suite, catches the typical problems: a loader that works in V8 but not JavaScriptCore, a file path that one runtime resolves differently, a WASI call that one implementation lacks. Include at least one test that measures a hot function’s throughput and compares it with a generous threshold; engine differences in tiering occasionally produce large performance gaps for specific code patterns, and it is better to discover them in CI than in a user’s issue. When a difference turns up, report it to the runtime with a minimal module — both Bun and JavaScriptCore move quickly.

Expected output

bun run main.ts loads the module through a direct import and prints 5; bun run ./wc.wasm input.txt prints word counts; and bun build produces a dist/ directory containing the bundle and the copied .wasm file, which runs from any working directory.

Gotchas

  • Assuming V8 behaviour. Bun uses JavaScriptCore. Benchmark and test there separately.
  • Packages taking the wrong environment branch. Check how the package detects its runtime.
  • Relative paths in Bun.file. They resolve against the working directory. Build URLs from import.meta.url.
  • Native addon fallbacks. Bun’s support for Node-API is broad but not complete. Prefer Wasm builds of packages.
  • Files missing from compiled executables. Runtime-read .wasm files are not embedded automatically.
  • Sharing one instance across concurrent requests. Per-call state in linear memory can be overwritten. Pool instances or keep state per request.
  • Unsupported proposals. A module built with very new features may fail to compile in JavaScriptCore. Target the features all your runtimes support.

Performance note

For a compute-heavy image filter module, Bun on JavaScriptCore ran 4% slower than Node 22 on V8 in steady state on the same laptop, while first-call latency was lower in Bun thanks to JavaScriptCore’s fast baseline tier. The differences varied by workload; measure your own.

Same Wasm image filter in Node and Bun Milliseconds per run of a Wasm image filter on a 12-megapixel image after warm-up, in Node 22 with V8 and in Bun with JavaScriptCore, plus the first call in each. ms per run Node 22, steady state 142 ms Bun, steady state 148 ms Node 22, first call 205 ms Bun, first call 181 ms

Frequently Asked Questions

Does Bun support Wasm threads? Bun supports Worker and SharedArrayBuffer, so threaded modules can run; test them, as threading support has evolved quickly.

Is bun run file.wasm the same as wasmtime? It runs WASI preview 1 modules, but it is not a hardened sandbox and does not support components.

Can Bun import the Component Model’s components? Not directly. Transpile components to core modules plus JavaScript with jco first.

Do Bun’s .wasm imports work with TypeScript? Add a module declaration for *.wasm or rely on Bun’s types; the exports are typed as functions taking numbers.

Does Bun cache compiled Wasm between runs? Bun caches transpiled JavaScript; compiled Wasm code caching depends on the version. Measure startup if it matters for CLI tools.

Can I use Bun.file() in browser bundles? No — it is a Bun runtime API. Use fetch with import.meta.url in code that must also run in browsers.

← Back to Wasm in Node.js, Deno & Bun