Building a Rust Wasm Library for npm

This page answers one task: you have a Rust crate with useful functionality — a parser, a codec, a geometry kernel — and you want JavaScript developers to npm install it and use it from a bundler, a plain browser page or Node, without learning anything about WebAssembly.

Prerequisites

  • [ ] A Rust crate that compiles for wasm32-unknown-unknown.
  • [ ] wasm-pack (or wasm-bindgen-cli) and Binaryen installed.
  • [ ] An npm account and a package name, scoped (@acme/geo) or unscoped.

What a good Wasm package looks like to its users

From the consumer’s side, a Wasm-backed package should look like any other package: import { simplify } from "@acme/geo", typed functions, documented errors, and no setup beyond, at most, an await init(). Everything Wasm-specific — where the .wasm file lives, how it is fetched or read, which glue is used in which environment — is the package author’s job. The work therefore splits into three parts: an API surface designed for JavaScript rather than mirrored from Rust; builds for the environments users run in; and a package.json that routes each environment to the right build.

From Rust crate to npm package The crate's public API is wrapped in a thin wasm-bindgen layer designed for JavaScript. wasm-pack builds it for bundler, web and Node targets. wasm-opt shrinks each build. A package.json with conditional exports routes each environment to its build, and npm publish ships the result with types and a README. Rust crate core logic JS-facing API layer #[wasm_bindgen] wrappers wasm-pack builds bundler, web, nodejs package.json exports routes per environment npm publish types + README

Step 1 — design the exported API for JavaScript

Do not export the crate’s Rust API directly. Write a thin layer that takes and returns JavaScript-friendly types — strings, numbers, typed arrays, plain objects via serde-wasm-bindgen — uses camelCase names (#[wasm_bindgen(js_name = simplifyPath)]), and returns Result<T, JsError> so errors become JavaScript Error objects with messages:

use wasm_bindgen::prelude::*;

#[wasm_bindgen(js_name = simplify)]
pub fn simplify(points: &[f64], tolerance: f64) -> Result<Vec<f64>, JsError> {
    if points.len() % 2 != 0 { return Err(JsError::new("points must be [x0, y0, x1, y1, ...]")); }
    Ok(geo_core::simplify(points, tolerance))
}

Prefer functions over long-lived objects where possible: exported structs need .free() to release Wasm memory, which JavaScript users forget. If objects are necessary, document free() and support using (explicit resource management) or FinalizationRegistry cleanup, as described in exporting Rust structs as JavaScript classes.

Step 2 — build for each target

wasm-pack’s --target decides the glue: bundler emits ES modules that import the .wasm file for bundlers to handle; web emits ES modules with an init() that fetches the module by URL; nodejs emits CommonJS that reads the file synchronously. Build the ones your users need into separate folders:

wasm-pack build --release --target bundler --out-dir pkg/bundler --out-name geo
wasm-pack build --release --target web     --out-dir pkg/web     --out-name geo
wasm-pack build --release --target nodejs  --out-dir pkg/node    --out-name geo

Each build runs wasm-opt with the profile’s settings. The three .wasm files are identical in content; only the glue differs, so a packaging script can keep one copy and point the others at it if size matters.

Step 3 — write package.json by hand

wasm-pack generates a package.json per build; for a multi-target package, write a top-level one that routes environments through conditional exports:

{
  "name": "@acme/geo",
  "version": "1.2.0",
  "type": "module",
  "exports": {
    ".": {
      "types": "./pkg/web/geo.d.ts",
      "node": "./pkg/node/geo.js",
      "browser": "./pkg/bundler/geo.js",
      "default": "./pkg/web/geo.js"
    },
    "./web": "./pkg/web/geo.js",
    "./geo_bg.wasm": "./pkg/web/geo_bg.wasm"
  },
  "files": ["pkg/**/*.js", "pkg/**/*.d.ts", "pkg/**/*.wasm", "README.md", "LICENSE"],
  "sideEffects": ["./pkg/bundler/geo.js"]
}

files keeps Rust sources and build artefacts out of the tarball. Exporting the .wasm path lets users who self-host it reference it explicitly. sideEffects must list the bundler glue, which instantiates the module on import, so tree-shaking does not drop it. The details of exports maps for Wasm packages are covered in publishing a Wasm package to npm.

Which build each consumer receives Bundlers resolving the browser condition get the bundler build, which imports the wasm file for the bundler to emit. Node gets the nodejs build, which reads the file from disk. Plain browser pages and CDNs get the web build with an init function. TypeScript reads the shared declaration file. consumer condition matched build initialisation Vite or webpack app browser pkg/bundler automatic on import Node or Deno node pkg/node synchronous on require/import plain page or CDN default pkg/web await init() TypeScript types geo.d.ts —

Step 4 — check the tarball and types

npm pack --dry-run          # list files that would be published
npx publint                 # lint exports, types and files
npx @arethetypeswrong/cli --pack .

publint catches exports pointing to missing files; “are the types wrong” catches TypeScript resolution problems across node16 and bundler resolution modes. Then install the tarball in three throwaway projects — a Vite app, a Node script and an HTML page loading from a local server — and run one call in each. That smoke test catches most packaging mistakes before users do.

Step 5 — publish with a README that shows real usage

The README’s first example decides adoption. Show installation, one import, one call, and the initialisation needed for the web build:

import { simplify } from "@acme/geo";          // bundlers and Node: ready on import
console.log(simplify([0, 0, 1, 0.1, 2, 0], 0.5));

import init, { simplify } from "@acme/geo/web"; // plain browsers
await init();

Mention the package’s size (compressed .wasm plus glue), supported environments, and whether it needs cross-origin isolation. Publish with provenance from CI (npm publish --provenance --access public) so users can verify the build came from your repository.

Versioning and compatibility

The package version and the crate version can move independently, but the JavaScript API is the contract users depend on. Treat renamed exports, changed argument types and changes to error messages users might match on as breaking. A wasm-bindgen upgrade that changes generated glue is not breaking for users as long as the exported API is unchanged, but test it like one — glue changes have broken environments before. Document minimum supported environments: the bundler build relies on the bundler supporting Wasm imports (Vite with a plugin, webpack 5’s asyncWebAssembly), and some features — reference types, multi-value, SIMD — need recent engines. If you enable SIMD, consider shipping a non-SIMD fallback build and choosing at runtime.

Size and loading considerations

Users notice package size. Build with opt-level = "z" or "s" if speed allows, strip debug info, run wasm-opt -Oz, and use wasm-bindgen’s --weak-refs and --reference-types where supported to shrink glue. Report the compressed size in the README and track it in CI, so a dependency change that doubles the module is caught before release. For very large modules, consider letting users load the module lazily: export an init() even from the bundler entry, so the module is fetched when first needed rather than on import.

Testing the package the way users consume it

Unit tests in Rust and wasm-bindgen-test cover the logic, but packaging bugs live in the boundary between the tarball and the consumer’s tooling. Automate the smoke test from step 4 in CI: run npm pack, then install the resulting .tgz into fixture projects kept in the repository — a Vite app, a webpack app, a Node ESM script, a Node CommonJS script if you support it, and a static HTML page served with a minimal server. Each fixture imports the package, calls one function and asserts the result; the browser fixtures run under Playwright. This catches the failures users report most: an exports condition that resolves to a missing file, a bundler that does not emit the .wasm asset, a Node build that tries to fetch a file path, and type declarations that resolve in one TypeScript mode but not another. Running the fixtures against the packed tarball rather than the workspace source matters, because workspace links hide missing entries in files.

Maintaining the package over time

Wasm packages accumulate a specific kind of maintenance: wasm-bindgen upgrades that must match between the crate and the CLI, bundler releases that change how Wasm assets are handled, and Node versions that change ESM resolution. Pin the wasm-bindgen version, update it deliberately with the fixture suite as the gate, and keep a changelog entry for any change to supported environments. Consumers file issues with their bundler configuration; a short “Troubleshooting” section in the README covering the usual causes — missing Wasm support in the bundler, a Content-Type problem on their server, a Content Security Policy without 'wasm-unsafe-eval' — answers most of them before they are asked.

Expected output

npm pack --dry-run lists only glue, declarations, .wasm files, README and LICENSE; publint and “are the types wrong” report no problems; the Vite app, the Node script and the HTML page each print the simplified path; and the published package page shows provenance and a 64 KB compressed size.

Gotchas

  • Exporting Rust-shaped APIs. Vec<u8> and snake_case leak through. Design a JavaScript-facing layer.
  • Objects without free(). Wasm memory leaks. Prefer functions, or document cleanup.
  • sideEffects: false for the bundler glue. Tree-shaking removes initialisation.
  • Missing files field. The tarball includes target/ or Rust sources.
  • Only testing in one environment. Smoke-test bundler, Node and plain browser.
  • Unannounced engine requirements. Document SIMD or reference-type requirements.

Performance note

For a geometry package, removing serde_json from the Wasm build, using opt-level = "z" and running wasm-opt -Oz reduced the compressed module from 212 KB to 64 KB without measurable slowdown on the benchmarked operations.

Compressed package module size Brotli-compressed size of the published Wasm module before and after removing serde_json, switching to opt-level z and running wasm-opt -Oz. KB compressed initial release build 212 KB without serde_json 118 KB opt-level z + wasm-opt -Oz 64 KB

Frequently Asked Questions

Do I need all three targets? No — many packages ship only the bundler and web builds. Add nodejs if Node users matter and your Node version cannot use the web build.

Can one build serve every environment? The web target works in Node 18+ if the .wasm is passed as bytes to init(), so a single build is possible with a small wrapper.

Should I commit the pkg folder? No — build it in CI and publish from there.

How do users self-host the .wasm file? Export its path and let init() take a URL or bytes.

Why does my package work in the repository but not after npm install? Workspace links bypass the files and exports fields; test the packed tarball in fixture projects.

← Back to Rust to Wasm Compilation Guide