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 .wasm file and the JavaScript that wraps it.
  • [ ] Familiarity with package.json exports and 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.

Which entry point each consumer gets Conditional exports in package.json route each environment to a matching loader. Node and Bun resolve the node condition, Deno resolves deno, browsers and bundlers resolve browser or default. All loaders share one wrapper and one .wasm file. import { parse } from "my-lib" node (Node, Bun) reads the .wasm with fs and import.meta.url deno fetch of a file URL, or direct import browser / default new URL(…, import.meta.url) for bundlers

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.

From one import to a running module in each environment The consumer imports the package. The resolver picks an entry point by condition. The entry point's loader locates the shared .wasm relative to itself and instantiates it, then hands the instance to the shared core wrapper, which exposes the same API everywhere. import "my-lib" same code everywhere conditional exports node / deno / browser environment loader fs or fetch shared .wasm one binary core wrapper same public API

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 types first and environment conditions before default.
  • Binary not published. Include it in files and 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 .wasm builds 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.

Time to a ready instance by environment Milliseconds from import to a usable instance of the same 900 KB module through the package's environment-specific loaders. ms to ready browser (streaming) 41 ms Node 22 (readFile) 29 ms Deno 2 (fetch file URL) 32 ms Bun (readFile) 27 ms

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.

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