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 .wasm module 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).

The layers that run Wasm in a server-side JavaScript runtime Application code calls a loader that reads or fetches the .wasm bytes. The runtime's host layer provides file access, module resolution, permissions and WASI. Underneath, the JavaScript engine compiles and runs the module: V8 in Node and Deno, JavaScriptCore in Bun. application code import { parse } from "my-lib" loader fs / fetch / direct .wasm import runtime host layer resolution, permissions, node:wasi JavaScript engine V8 (Node, Deno) or JavaScriptCore (Bun) operating system files, threads, memory

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.

Loading options in Node.js, Deno and Bun All three runtimes support reading bytes and calling WebAssembly.instantiate. Direct .wasm imports are flag-gated in Node and built in to Deno and Bun. fetch of file URLs works in Deno and Bun and in recent Node versions. Deno requires read permission for runtime file access. method Node.js Deno Bun read bytes + instantiate fs/promises Deno.readFile (--allow-read) fs or Bun.file import from .wasm experimental flag built in built in fetch(file URL) + streaming recent versions yes (--allow-read) yes WASI preview 1 node:wasi via shims built in + node:wasi

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.

A portable loader across runtimes The loader computes the .wasm URL from import.meta.url, reads the bytes with Deno.readFile under Deno or fs/promises under Node and Bun, compiles once, and creates instances from the compiled module as needed. new URL(…, import.meta.url) module-relative Deno? Deno.readFile Node / Bun fs/promises readFile WebAssembly. compile once per process new Instance(module) per use

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.

Choosing how to load Wasm on the server Application code in one runtime uses that runtime's simplest loader. A published library uses conditional exports with per-environment loaders and an init escape hatch. CLI tools optimise startup with small modules. Long-running servers compile once and instantiate per request or tenant. What kind of code loads the module? app in one runtime direct import (Deno, Bun) or fs + compile (Node) npm library conditional exports + init(source) server or CLI compile once; instantiate per request or run

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.

Steady-state time for the same Wasm image filter Milliseconds per run after warm-up for a Wasm image filter on a 12-megapixel image in Node 22, Deno 2 and Bun on the same laptop. Node and Deno share V8 and perform alike; Bun uses JavaScriptCore. ms per run (lower is better) Node 22 (V8) 142 ms Deno 2 (V8) 143 ms Bun (JavaScriptCore) 148 ms

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. Use import.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 require and __dirname may fail. Prefer ES-module targets.
  • Treating node:wasi as 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

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.

← Back to JS/Wasm Interop & Memory Management