Targeting Node and Browsers from One Wasm Package
This page answers one task: you maintain a library backed by WebAssembly and want a single npm package that works when imported from a browser app built with Vite or webpack, from a Node server, and from Deno or Bun — without asking users to configure anything.
Prerequisites
- [ ] A
.wasmfile and the JavaScript that wraps it. - [ ] Familiarity with
package.jsonexportsand ES modules. - [ ] Optionally, a toolchain that can emit different glue targets (wasm-bindgen
web/bundler/nodejs, Emscripten environments).
Why one loader is not enough
The .wasm binary is the same everywhere; what differs is how it gets loaded. Browsers fetch it over HTTP from a URL relative to the JavaScript. Bundlers
want to see the .wasm file referenced in a way they recognise, so they can copy and fingerprint it. Node reads it from disk, and older Node versions
lack fetch for file: URLs. Deno and Bun accept most browser-style code but have their own quirks. A loader written for one environment usually
breaks in another: fs does not exist in browsers, and fetch("./m.wasm") resolves against the page URL, not the package.
The solution is a small loader per environment, all sharing the same core wrapper and the same .wasm file, selected automatically by conditional
exports in package.json. Consumers write import { parse } from "my-lib" everywhere; the resolver picks the right entry point.
Step 1 — structure the package
my-lib/
package.json
dist/
core.js // wrapper: takes an instantiated module, exposes the public API
load-node.js // Node/Bun loader
load-browser.js // browser + bundler loader
index.node.js // export * from core, with Node loader
index.browser.js // export * from core, with browser loader
my_lib_bg.wasm // the single binary
index.d.ts
Keep all API logic in core.js, which receives an instantiated module and never loads anything itself. The entry files only choose a loader and pass the
result to the core, so behaviour cannot drift between environments.
Step 2 — write the loaders
// load-browser.js — browsers and bundlers
export async function load(imports) {
const url = new URL("./my_lib_bg.wasm", import.meta.url); // bundlers recognise this pattern and emit the file
const { instance } = await WebAssembly.instantiateStreaming(fetch(url), imports);
return instance;
}
// load-node.js — Node and Bun
import { readFile } from "node:fs/promises";
export async function load(imports) {
const bytes = await readFile(new URL("./my_lib_bg.wasm", import.meta.url));
const { instance } = await WebAssembly.instantiate(bytes, imports);
return instance;
}
The new URL("./file.wasm", import.meta.url) pattern is the key to bundler support: Vite, webpack 5, Rollup (with a plugin), esbuild (with a loader) and
Parcel detect it, copy the .wasm into the output and rewrite the URL, as described in
resolving Wasm URLs with import.meta.url.
Do not compute the URL dynamically or hide it behind a variable; static analysis needs the literal.
Step 3 — declare conditional exports
{
"name": "my-lib",
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts",
"deno": "./dist/index.browser.js",
"node": "./dist/index.node.js",
"browser": "./dist/index.browser.js",
"default": "./dist/index.browser.js"
},
"./my_lib_bg.wasm": "./dist/my_lib_bg.wasm"
},
"files": ["dist"],
"sideEffects": false
}
Order matters: resolvers use the first matching condition. types comes first for TypeScript. Bundlers targeting browsers set the browser condition;
Node and Bun match node; Deno matches deno. Exporting the .wasm path explicitly lets advanced users load it themselves — for example to precompile it
in a worker or serve it from a CDN. files ensures the binary is published.
Step 4 — expose an explicit initialiser
Top-level await in the entry files makes the API ready on import, which is convenient. Also export an explicit init(source?) that accepts bytes, a
Response, a URL or a compiled WebAssembly.Module. Consumers with unusual environments — a service worker, an edge runtime without file access, a
custom CDN path — can then bypass the automatic loader:
import { init, parse } from "my-lib";
await init(await WebAssembly.compileStreaming(fetch("https://cdn.example.com/my_lib_bg.3f9a.wasm")));
wasm-bindgen’s web target follows this pattern with its default init function; mirror it in your own wrapper so users of your package have the same
escape hatch.
Step 5 — test the package as consumers install it
Most packaging bugs are invisible from inside the repository, because files exist there that are not published, and paths resolve against the source tree. Test the packed artefact:
npm pack # produces my-lib-1.4.0.tgz
cd /tmp/consumer-node && npm i /path/to/my-lib-1.4.0.tgz && node -e 'import("my-lib").then(m => console.log(m.parse("x")))'
cd /tmp/consumer-vite && npm i /path/to/my-lib-1.4.0.tgz && npx vite build && npx vite preview
deno run --allow-read npm:my-lib@file:/path/to/my-lib-1.4.0.tgz # or an import map pointing at the unpacked package
bun add /path/to/my-lib-1.4.0.tgz && bun -e 'import { parse } from "my-lib"; console.log(parse("x"))'
Automate these as CI jobs. They catch the classic failures: the .wasm missing from files, a loader resolving relative to the working directory, a
bundler that does not copy the binary because the URL was not a literal, and a condition order that sends Bun down the browser path.
Workers, edge runtimes and server-side rendering
Three more environments deserve a thought. Web Workers resolve the browser condition and can use the browser loader unchanged, as long as the
bundler handles new URL inside worker code — modern bundlers do. Edge runtimes such as Cloudflare Workers often forbid compiling Wasm from bytes at
runtime and require the module to be imported as a binding instead; they usually resolve a workerd or worker condition, so add an entry that imports
the .wasm as a module (import wasm from "./my_lib_bg.wasm") and instantiates it with WebAssembly.instantiate(wasm, imports). And frameworks that
render on the server — Next.js, SvelteKit, Nuxt — import the package in Node during rendering and in the browser after hydration; the conditional
exports handle that automatically, but avoid touching browser-only APIs at module top level in the shared core, or server rendering will fail before the
loader even runs.
Size, caching and versioning
One package serving every environment should still be lean. Publish the optimised .wasm only — run wasm-opt before packing, and strip debug sections —
and keep debug builds in a separate package or a GitHub release asset if users need symbolication. Bundlers fingerprint the copied binary, so browsers
cache it long-term; when loading from a CDN instead, include the version or a content hash in the URL. If a breaking change in the binary’s ABI is
possible — a new export signature, a different memory layout — check at load time that the wrapper and the binary match, using an exported version
function, so that a stale cached binary fails with a clear message rather than corrupting data. Finally, document the minimum versions of each runtime
the package supports, based on the WebAssembly features the binary uses; a binary built with newer proposals enabled may load in current browsers but not
in an older Node LTS that your users still run.
Expected output
The same my-lib@1.4.0 tarball works in a Node 20 script, a Vite production build, Deno 2 and Bun; each environment loads the single my_lib_bg.wasm
shipped in the package; and CI runs all four consumer checks on every release.
Gotchas
- Dynamic URLs.
new URL(name, import.meta.url)with a variable is invisible to bundlers. Use a literal. - Wrong condition order. Put
typesfirst and environment conditions beforedefault. - Binary not published. Include it in
filesand check the tarball contents. - CommonJS consumers. ES-only packages need
import()from CommonJS. Document it or add a CJS entry. - Different binaries per environment. Shipping several
.wasmbuilds multiplies size. Share one binary where possible.
Performance note
The browser entry with instantiateStreaming reached a ready instance 18 ms sooner than a version that fetched the bytes and then compiled them, on a 900 KB
module over a fast connection. In Node, the readFile loader took 1 ms plus compilation; the package’s overhead beyond compiling the binary was negligible
in every environment.
Frequently Asked Questions
Can I inline the .wasm as base64 to avoid all this?
Yes, for small modules — every environment can decode it — at the cost of about 33% more bytes and no streaming compilation; see
inlining small Wasm modules as base64.
Should I use wasm-bindgen’s bundler target?
It relies on bundlers supporting .wasm ES module imports. The web target with import.meta.url works more broadly.
What about React Native or other runtimes? They may lack WebAssembly. Provide a JavaScript fallback or document that the package requires it.
Do I need a browser field?
The legacy browser field is unnecessary when exports conditions are used, but some older tools still read it.
How do I support TypeScript users?
Ship one index.d.ts shared by all entry points, since the API is identical, and list it under the types condition first.
Related
- Loading Wasm in Node.js with ES modules — the Node loader.
- Running Wasm in Deno — Deno’s resolution rules.
- Publishing a Wasm package to npm — the publishing side.
- Designing a promise-based API around a Wasm module — the shared core wrapper.
← Back to Wasm in Node.js, Deno & Bun