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(orwasm-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.
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.
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: falsefor the bundler glue. Tree-shaking removes initialisation.- Missing
filesfield. The tarball includestarget/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.
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.
Related
- Best practices for wasm-pack configuration — build options.
- Exposing Wasm from a library with a clean ESM API — the JavaScript wrapper.
- Customising TypeScript output from wasm-bindgen — better types.
- Publishing Wasm release artifacts from CI — automating releases.
← Back to Rust to Wasm Compilation Guide