Publishing a Wasm Package to npm

This guide answers one task: package a compiled WebAssembly module so that someone can npm install it and have it work in a bundler, in Node and in a browser without reading your build instructions.

Prerequisites

  • [ ] A module built with wasm-pack, or your own glue if you hand-rolled it.
  • [ ] An npm account and a decision about the package name and scope.
  • [ ] A consumer project to test against before publishing.
  • [ ] A version policy for the module’s interface, not just for the package.

Pick the right target, or publish several

wasm-pack builds for four targets and they produce incompatible glue. Choosing wrongly is the most common reason a published package does not work in a consumer’s project.

bundler produces ES modules with an import of the .wasm, which Webpack, Vite and Rollup understand. web produces an ES module with an explicit init() the consumer calls, usable directly in a browser with no bundler. nodejs produces CommonJS that reads the binary from disk. no-modules produces a script that assigns a global.

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

Publishing all three in one package and routing between them with conditional exports is more work than publishing one, and it is what makes a package feel like it just works.

One package, three consumers Conditional exports route a bundler to the bundler build, a browser import to the web build and Node to the CommonJS build, so consumers get a working artifact without choosing one themselves. package.json exports bundler build Webpack, Vite, Rollup imports the .wasm web build no bundler required explicit init() node build reads from disk CommonJS

The package manifest

Conditional exports are what make one package serve every consumer. The order matters: the first matching condition wins, so more specific conditions come first.

{
  "name": "@example/engine",
  "version": "1.4.0",
  "type": "module",
  "sideEffects": ["./pkg/bundler/engine_bg.js"],
  "exports": {
    ".": {
      "types": "./types/index.d.ts",
      "node": {
        "import": "./pkg/node/engine.js",
        "require": "./pkg/node/engine.js"
      },
      "browser": "./pkg/web/engine.js",
      "default": "./pkg/bundler/engine.js"
    },
    "./web": "./pkg/web/engine.js",
    "./package.json": "./package.json"
  },
  "files": ["pkg/", "types/", "README.md"],
  "engines": { "node": ">=18" }
}

sideEffects is not optional for a bundler build: the generated glue initialises module state on import, and a bundler that treats it as side-effect-free will tree-shake it away, producing a package that installs and then does nothing.

Exporting ./web explicitly gives consumers an escape hatch when the conditional routing picks the wrong build for their setup — which happens, and a documented alternative saves an issue report.

Types that match reality

wasm-pack generates a .d.ts from the Rust signatures, and shipping it is the difference between a package that feels native to TypeScript users and one that does not.

// pkg/bundler/engine.d.ts, generated
export function process(input: Uint8Array): Uint8Array;
export function abi_version(): number;
export default function init(module_or_path?: InitInput): Promise<InitOutput>;

Check the generated types into the published tarball rather than regenerating on install, and verify them against a consumer project before publishing. The most common mismatch is the init signature, which differs between targets — a consumer importing the bundler build does not call init at all, while a consumer of the web build must.

Document that difference in the readme with a working example per target. It is the first question every consumer asks.

Keeping the tarball honest

Publish only what consumers need. A package that ships the Rust source, the target directory and the test fixtures is tens of megabytes and installs slowly for no benefit.

npm pack --dry-run
# npm notice 📦  @example/engine@1.4.0
# npm notice 4.1kB  pkg/bundler/engine.js
# npm notice 96.7kB pkg/bundler/engine_bg.wasm
# npm notice 3.8kB  pkg/web/engine.js
# npm notice 96.7kB pkg/web/engine_bg.wasm
# npm notice 5.2kB  pkg/node/engine.js
# npm notice 96.7kB pkg/node/engine_bg.wasm
# npm notice 2.1kB  types/index.d.ts
# npm notice total files: 7, unpacked size: 305.3kB

Three copies of the binary is the cost of serving three targets, and it is usually acceptable — npm compresses the tarball, and identical binaries compress well. If the size genuinely matters, publish the bundler build as the main package and the others as optional subpath exports fetched separately.

Run npm pack --dry-run before every publish. It is the only reliable way to see what you are actually shipping, and it regularly reveals a node_modules or a target/ that files did not exclude.

Versioning the interface, not just the package

A package version communicates to humans; the module’s ABI communicates to code. Export both, and check the second at load.

#[wasm_bindgen]
pub fn abi_version() -> u32 { 3 }
import init, { abi_version } from '@example/engine';
await init();
if (abi_version() !== EXPECTED_ABI) {
  throw new Error(`engine ABI ${abi_version()} does not match this loader (${EXPECTED_ABI})`);
}

Bump the package’s major version whenever the ABI changes, and say so in the changelog in those terms. A consumer who reads “breaking: ABI 2 → 3, process now takes a length” knows exactly what to do; one who reads “improvements and fixes” finds out at runtime.

What runs before a publish Each target is built, the package contents are inspected, a consumer project installs the packed tarball and runs against it, and only then is the package published. build 3 targets same commit npm pack --dry-run see what ships install the tarball in a real consumer npm publish with provenance The third box is the one people skip, and it is the one that catches a broken exports map before anyone else does.

Testing the package before anyone else does

The most valuable step in this whole process is installing the packed tarball into a real project and running it. Every failure mode described above — a wrong exports condition, a missing file, a tree-shaken glue module, a type that does not match — is visible immediately and invisible from inside your own repository.

npm pack                                        # produces example-engine-1.4.0.tgz
cd ../consumer-app
npm install ../engine/example-engine-1.4.0.tgz
npm run build && npm test

Do it for each consumer shape you claim to support: a Vite application, a Webpack application, and a Node script. Three small test projects in a fixtures/ directory, driven from one script, turn this from a manual ritual into a check the pipeline runs on every change.

#!/usr/bin/env bash
set -euo pipefail
TARBALL=$(npm pack --silent)
for consumer in fixtures/vite fixtures/webpack fixtures/node; do
  ( cd "$consumer" && npm install "../../$TARBALL" --no-save && npm test )
done

A package that passes that script is one you can publish without holding your breath. A package that has never been installed from a tarball is one where the first consumer does your integration testing for you, in public.

Expected output

A consumer installing the package should be able to use it in three lines, in whichever environment they are in:

// bundler
import { process } from '@example/engine';
const out = process(input);
// browser, no bundler
import init, { process } from 'https://esm.sh/@example/engine/web';
await init();
const out = process(input);
// node
import { process } from '@example/engine';
const out = process(input);

If any of those requires a note in the readme explaining a workaround, the exports map is wrong and worth fixing before the package has many consumers.

What reaches the consumer wasm-pack writes a package directory; the files field decides what npm uploads; the consumer's bundler resolves the entry and must be able to serve the binary beside it. pkg/ directory js, d.ts, wasm files + exports what ships npm publish tarball to registry consumer bundler resolves and serves The commonest broken publish is a files field that omits the .wasm — install succeeds, import fails at runtime. Ship a bundler target and a web target under exports conditions, so script tags and bundled apps both work. Keep the glue and the binary versioned together; they are one artifact, not two.

Gotchas

  • sideEffects unset. Bundlers tree-shake the glue and the package silently does nothing.
  • One target published as if universal. Works for the consumer setup you tested and no other.
  • files missing the pkg directory. Publishes a package with no code; npm pack --dry-run shows it.
  • Types regenerated at install. Consumers do not have your toolchain; ship the generated files.
  • No ABI export. A version mismatch between glue and binary becomes a runtime mystery.
  • Publishing from a dirty tree. Publish from CI on a tag, so what ships is what is in the repository.

Performance note

For a module of 96.7 kB compressed, the published tarball with three targets was 305 kB unpacked and 118 kB packed — the binaries being identical means they compress almost to the size of one. Install time was under a second. The measurable cost to a consumer is the one binary their bundler actually includes, not the tarball, which is what makes publishing all three targets a reasonable default.

Frequently Asked Questions

Should I publish the Rust source too? Not in the npm package. Link to the repository in the readme; consumers who want the source want the repository, not a copy inside their node_modules.

How do I handle a package used in both a browser and a worker? The same build works in both — a worker is not a separate condition. What differs is how the consumer loads it, which belongs in the readme rather than in the exports map.

What about provenance and signing? Publishing from CI with npm’s provenance support links the published artifact to the workflow run and the commit that produced it, which for a package containing a compiled binary is worth more than for a pure-JavaScript one — consumers cannot easily read what they installed.

Is wasm-pack publish worth using? It wraps npm publish and is fine for a single-target package. For a multi-target package with a hand-written manifest, run npm publish directly so the manifest you wrote is the one that ships.

A published package is a promise about an interface. Keeping that promise is mostly about the manifest and the tarball — the module itself is the easy part.

Publishing is the last mile, and it deserves the same care as the code it delivers.

← Back to ESM Bindings & Module Generation