Wasm in Node.js, Deno & Bun
WebAssembly is often introduced as a browser technology, but a large share of real modules run on servers and developer machines: inside Node.js
build tools, CLI utilities, API handlers, test runners and edge functions. Three JavaScript runtimes dominate there — Node.js on V8, Deno on V8, and Bun
on JavaScriptCore — and each implements the same standard WebAssembly JavaScript API that browsers do. The core of a module therefore runs unchanged
everywhere. What differs, and what this topic is about, is everything around it: how the .wasm file is located and loaded, how the module gets its
imports, how WASI programs get files and arguments, how one npm package serves all three runtimes and browsers at once, and when WebAssembly is the right
replacement for native code.
The guides below take each of those questions in turn. This page gives the shared mental model and a walk-through that works across the runtimes, so the individual guides can focus on the details that differ.
Prerequisites
- [ ] Familiarity with the WebAssembly JavaScript API (
compile,instantiate,Module,Instance,Memory), as in instantiating one module many times. - [ ] Node.js 20+, and optionally Deno 2 and Bun 1.1+ for comparison.
- [ ] A
.wasmmodule to experiment with — a hand-written one is enough.
One API, three hosts
All three runtimes embed a JavaScript engine that includes a WebAssembly implementation: V8 in Node and Deno, JavaScriptCore in Bun. The engine compiles module bytes to machine code, enforces the sandbox, manages linear memory and implements the calls between JavaScript and Wasm. On top of the engine, each runtime provides its own host environment: file-system APIs, module resolution, permissions, networking, and in some cases a WASI implementation.
For WebAssembly code, the engine layer is what determines performance and feature support — SIMD, threads, exception handling, garbage collection — and it moves with the runtime’s engine version. The host layer is what determines how you get a module running. That split explains most of the practical differences you will meet: a feature works in Node and Deno but not yet in Bun (engine), or loading code works in Deno but not Node (host).
How each runtime finds and loads a module
Loading is where the runtimes differ most. Node reads files with fs and has no built-in direct .wasm import without an experimental flag. Deno
treats .wasm files as first-class modules that can be imported directly, and its fetch understands file: URLs — but reading files at runtime
requires a permission flag. Bun supports direct imports, Node’s fs, its own fast Bun.file() API and fetch of local files. In all three, the robust
way to locate a file shipped beside your code is new URL("./file.wasm", import.meta.url), which resolves relative to the module rather than the
working directory.
Whichever method you use, compile once per process and keep the WebAssembly.Module. Compilation is the expensive step — tens of milliseconds for a
megabyte-sized module — while instantiating from a compiled module takes well under a millisecond. Servers that instantiate per request, and CLI tools
that run several instances, both benefit. The Node specifics are in
loading Wasm in Node.js with ES modules.
Step-by-step: one loader that runs in all three
The following loader works unchanged in Node 20+, Deno 2 and Bun, which makes it a good default for application code that does not need a runtime-specific feature.
// wasm.js
const url = new URL("./math.wasm", import.meta.url);
async function readBytes() {
if (typeof Deno !== "undefined") return Deno.readFile(url); // needs --allow-read=./math.wasm
const { readFile } = await import("node:fs/promises"); // Node and Bun
return readFile(url);
}
const module = await WebAssembly.compile(await readBytes());
export function createInstance(imports = {}) {
return new WebAssembly.Instance(module, { env: { log: console.log, ...imports } });
}
export const { exports: wasm } = createInstance();
Three things make it portable. The URL is computed from import.meta.url, so the file is found regardless of the working directory. Deno is detected and
uses its own reader, which keeps Deno’s permission model intact. And compilation happens once, at module evaluation, with top-level await, so importers
never see a half-initialised module; createInstance reuses the compiled module for additional instances, for example per worker thread.
Running it:
node main.js
deno run --allow-read=./math.wasm main.js
bun main.js
For packages published to npm, the same idea is better expressed with conditional exports and one small loader per environment, so bundlers and browsers are served as well — the subject of targeting Node and browsers from one Wasm package.
Using toolchain output on the server
Most modules arrive with generated JavaScript. wasm-bindgen offers several targets: nodejs emits CommonJS that reads the file synchronously next to the
glue; web emits an ES module with an init function using fetch and import.meta.url; deno emits glue tailored to Deno; bundler expects a bundler
to handle .wasm imports. Emscripten’s -sENVIRONMENT=node output uses Node’s fs, while web output relies on fetch; -sEXPORT_ES6 -sMODULARIZE
makes the result an importable factory. For server-side use, the most portable choice is usually an ES-module target whose init accepts bytes or a
compiled module explicitly — then the application decides how to load, and the same glue works in every runtime. The bindings side is covered in
reading the glue code wasm-bindgen generates.
WASI on the server
Many server-side modules are not libraries but programs compiled for WASI — formatters, linters, converters — that expect to read arguments, environment
variables and files through wasi_snapshot_preview1 imports. Node ships node:wasi, Bun ships a WASI implementation and runs such modules directly with
bun run tool.wasm, and Deno relies on JavaScript shims. All of them implement preview 1 for core modules; preview 2 components need jco to transpile them
into core modules plus JavaScript, or a component runtime such as wasmtime.
Two cautions apply. Node’s documentation is explicit that node:wasi is not a hardened sandbox: it maps file access through to the host, which is fine for
trusted tools and wrong for untrusted code. And WASI programs run synchronously to completion, so a long-running tool blocks the event loop unless it runs
in a worker thread. The details are in
running WASI modules in Node.js.
Moving data in and out on the server
Data exchange works exactly as in browsers: numbers cross directly, everything else goes through linear memory. Node adds one convenience and one trap.
The convenience is Buffer, which is a Uint8Array subclass: a Buffer can be written into linear memory with set, and Buffer.from(memory.buffer, ptr, len) creates a zero-copy Buffer view over a region of the module’s memory that can be handed straight to fs.writeFile, crypto or a socket. The
trap is that such a view is just as fragile as any typed-array view: it becomes invalid when memory grows or when the module frees or reuses the region,
so copy it (Buffer.from(view)) before the next call into the module unless the consumer finishes with it synchronously. Streams are the other
server-side pattern. Large inputs — uploads, log files, database exports — should flow through the module in chunks rather than being read whole, which
keeps memory bounded regardless of input size; a TransformStream or Node Transform wrapping the module’s update/finish API is the natural shape.
The techniques carry over directly from
streaming file uploads into Wasm memory.
Choosing a loading strategy by use case
The right loader depends less on the runtime than on what the code is. Application code that runs in exactly one runtime can use that runtime’s most
convenient option: direct .wasm imports in Deno and Bun, fs plus WebAssembly.compile in Node. Libraries published to npm should use conditional
exports with one loader per environment and an explicit init escape hatch, because their consumers include bundlers, browsers and edge runtimes the
author does not control. CLI tools care about startup: keep the module small, load it with a synchronous read where the runtime allows, and avoid
compiling more than one module at startup. Long-running servers care about steady state and isolation: compile at startup, instantiate per request or
per tenant if state must not leak, and pool instances when instantiation shows up in profiles. Edge functions sit in between, with cold starts on every
new isolate, and benefit most from small modules and from platforms that precompile at deploy time.
Security on the server
The WebAssembly sandbox protects the host process from the module’s memory accesses: a module cannot read or write outside its linear memory, and it can
only call the functions it was given as imports. On the server, that guarantee is most valuable when the module comes from somewhere less trusted than
the rest of the code — a user-supplied plugin, a third-party transformation, a format parser exposed to untrusted input. The guarantee is only as narrow
as the imports, though: an import that calls fs.readFile with a path chosen by the module hands the module the whole file system. Keep imports minimal
and validate their arguments in JavaScript. For untrusted code, add resource limits that the JavaScript runtimes do not provide by themselves — CPU time
and memory caps — by running the module in a worker you can terminate, or in a dedicated runtime such as wasmtime with fuel and memory limits, as
discussed in
sandboxing untrusted code with Wasm.
Engines and performance
Node and Deno share V8, so they compile Wasm with the same tiers — Liftoff for fast startup, TurboFan for optimised code — and usually perform identically for the same module. Bun runs JavaScriptCore, whose BBQ and OMG tiers have different characteristics: often faster first calls, sometimes slightly different steady-state performance depending on the code. None of the three persists compiled Wasm code between process runs by default the way browsers do, so CLI tools pay compilation on every start; keeping modules small and using baseline-tier compilation makes that acceptable for most tools. For long-running servers, startup cost is paid once and steady-state throughput dominates. The engine details are in how V8 compiles Wasm with Liftoff and TurboFan and how Safari runs Wasm.
Threads, workers and concurrency
Server-side runtimes do not need cross-origin isolation headers, so SharedArrayBuffer and Wasm threads are available by default. Node uses
worker_threads, Deno and Bun use the web-standard Worker. The same patterns as in browsers apply: compile the module once and post the
WebAssembly.Module to each worker, give each worker its own instance (or share one memory for a threaded build), and keep long computations off the main
event loop so the server keeps answering requests. For request handlers that call a module concurrently, remember that one instance has one linear memory:
if the module keeps per-call state in memory, either serialise calls, keep a small pool of instances, or design the module so each call’s state is
independent. The thread-pool mechanics are covered in
building a Wasm thread pool.
Replacing native addons
The strongest server-side argument for WebAssembly is distribution. Native Node addons need either a compiler at install time or a matrix of prebuilt binaries for every platform, architecture and Node ABI, and they are a frequent cause of failed installs. A Wasm build is one portable file that installs everywhere, works in Deno and Bun as well, and is memory-isolated from the process. The cost is usually a 1.1–2× slowdown for computation and some engineering to move I/O and threading into JavaScript. Many widely used packages now ship Wasm as the default and keep native builds as optional accelerators; the migration path is described in replacing a native Node addon with Wasm.
Debugging and profiling on the server
The server runtimes expose the same debugging tools as browsers, through different entry points. Node and Deno accept --inspect, which lets Chrome
DevTools attach, set breakpoints in Wasm functions, and — with DWARF debug information and the C/C++ DevTools extension — step through source code. CPU
profiles taken with --cpu-prof in Node or through the inspector show Wasm functions by name when the name section is present, which makes hot spots in
the module visible next to the JavaScript that calls it. Bun supports the WebKit inspector protocol with --inspect as well. For memory, watch
memory.buffer.byteLength alongside process.memoryUsage(); linear memory appears in the arrayBuffers figure in Node. The Node workflow is described in
debugging Wasm in Node.js with the inspector.
Gotchas and failure modes
- Paths resolved against the working directory.
readFile("./m.wasm")breaks when the program runs from elsewhere. Useimport.meta.url. - Compiling per call or per request. Keep the compiled module; instantiate from it.
- Deno permission prompts. Runtime file reads need
--allow-read; scope it to the file. - Node-only glue in Deno or Bun. Glue that uses
requireand__dirnamemay fail. Prefer ES-module targets. - Treating
node:wasias a sandbox. It is not hardened. Run untrusted code in a dedicated runtime. - Engine feature gaps. A module using a very new proposal may compile in V8 but not JavaScriptCore. Test every runtime you support.
- Blocking the event loop. Long synchronous Wasm calls stall servers. Use worker threads.
Verification
Check a setup with a short script per runtime that imports the module from a different working directory, calls an export, and prints the result and the time to readiness:
cd /tmp && node /path/to/app/main.js && deno run --allow-read=/path/to/app/math.wasm /path/to/app/main.js && bun /path/to/app/main.js
All three should print the same result. For packages, run the consumer checks from the packaging guide against the packed tarball, since that is what users install.
Guides in this topic
- Loading Wasm in Node.js with ES modules — readFile, fs streams and import.meta.url for loading modules in modern Node.
- Running WASI modules in Node.js — the node:wasi module, preopens, and its stability caveats.
- Running Wasm in Deno — permissions, direct .wasm imports, and Deno’s WebAssembly-specific behaviour.
- Running Wasm in Bun — Bun’s loader for .wasm, WASI support, and compatibility gaps with Node.
- Targeting Node and browsers from one Wasm package — conditional exports and per-environment loaders in a single npm package.
- Replacing a native Node addon with Wasm — trading node-gyp builds for one portable module, and measuring what it costs.
Frequently Asked Questions
Is WebAssembly faster than JavaScript on the server? For compute-heavy code with predictable types — parsing, compression, image processing, cryptography — usually yes, often 1.5–3×. For code dominated by object manipulation or I/O, JavaScript is often as fast and simpler.
Which runtime is best for Wasm? All three run modules well. Choose the runtime for the rest of the application; test Wasm-heavy code in it, especially if it is Bun with its different engine.
Do I need cross-origin isolation for threads on the server?
No. SharedArrayBuffer is available in Node, Deno and Bun without special headers.
Can server-side runtimes run Component Model components? Not natively yet. Transpile them with jco, or run them in wasmtime.
How do I cache compiled modules across process restarts? None of the runtimes do it by default for Wasm. For short-lived tools, keep modules small; for servers, compile once at startup.
Can the same module run in a browser and on the server? Yes — the module is identical. Only the loader and the import object differ, which is why separating them from the core wrapper pays off.
What about TypeScript?
Runtimes that run TypeScript directly — Deno, Bun, and Node with type stripping — load Wasm the same way. Types for exports come from the toolchain’s
generated .d.ts files or, in Deno, from the module’s export signatures.
Related
- Serializing data with serde-wasm-bindgen — data exchange that works the same everywhere.
- Running Wasm modules with the wasmtime CLI — a dedicated runtime for comparison.
- Deploying Wasm to Cloudflare Workers — the edge, another JavaScript host.
- Publishing a Wasm package to npm — shipping the result.
← Back to JS/Wasm Interop & Memory Management