Exposing Wasm from a Library with a Clean ESM API

This page answers one task: you publish a library whose core is WebAssembly, and consumers should import { compress } from "fastzip" and call it — without knowing about init(), glue files, .wasm URLs or linear memory — while bundlers can still tree-shake, split and preload it properly.

Prerequisites

  • [ ] A WebAssembly module with generated glue (wasm-bindgen web target or Emscripten ES module output).
  • [ ] An npm package you control, with package.json exports.
  • [ ] Familiarity with how bundlers handle new URL(..., import.meta.url).

What consumers should and should not see

Generated glue exposes the module’s mechanics: an init function that must be awaited first, functions that take pointers or Uint8Arrays with specific lifetime rules, classes with free() methods, and a .wasm file that must be served correctly. Leaving that surface in front of consumers forces every application to learn it, and makes the library impossible to change internally without breaking callers.

A clean ES module API looks like any other JavaScript library: named exports of ordinary functions taking and returning ordinary values, documented types, no initialisation ceremony, and no side effects at import time. The WebAssembly is an implementation detail behind those exports. That also leaves room to change the implementation later — switch toolchains, add a JavaScript fallback, move work to a worker — without a major version.

Layers of a Wasm-backed library package Consumers import named functions from the package entry. Those functions lazily initialise the Wasm engine once and convert values at the boundary. Generated glue and the .wasm binary sit below as private files, reachable only through an optional advanced entry for custom loading. public exports compress(data), decompress(data) lazy initialisation single-flight engine() value conversion Uint8Array in, Uint8Array out generated glue + .wasm private implementation files advanced entry (opt-in) init(module or URL)

Step 1 — hide initialisation behind lazy, single-flight loading

Each public function awaits a shared promise that loads the engine on first use:

// src/index.js
import initGlue, * as glue from "./generated/fastzip.js";

let engine;
function ready() {
  engine ??= initGlue({ module_or_path: new URL("./generated/fastzip_bg.wasm", import.meta.url) })
    .then(() => glue)
    .catch((e) => { engine = undefined; throw e; });
  return engine;
}

export async function compress(data, { level = 6 } = {}) {
  if (!(data instanceof Uint8Array)) throw new TypeError("compress: data must be a Uint8Array");
  return (await ready()).compress(data, level);
}

The literal new URL("…", import.meta.url) lets bundlers find, copy and hash the .wasm file. Initialisation happens once, on first call, and failures can be retried. The pattern is discussed in designing a promise-based API around a Wasm module.

Step 2 — keep modules side-effect-free

Importing the package must not fetch or compile anything; only calling a function should. Avoid top-level await in the entry module and do not start loading at import time. Declare it in package.json:

{
  "name": "fastzip",
  "type": "module",
  "sideEffects": false,
  "exports": {
    ".": { "types": "./dist/index.d.ts", "default": "./dist/index.js" },
    "./advanced": { "types": "./dist/advanced.d.ts", "default": "./dist/advanced.js" }
  },
  "files": ["dist"]
}

sideEffects: false tells bundlers they may drop the package entirely when nothing is used, and drop unused exports when something is. An application that imports fastzip but only uses it on one rarely visited route pays nothing until that route calls compress.

Step 3 — split optional functionality into subpath exports

If parts of the library need separate, large modules — a second codec, a dictionary file, a threaded build — expose them through subpaths, each with its own lazily loaded .wasm:

import { compress } from "fastzip";            // core module only
import { trainDictionary } from "fastzip/dict"; // loads dict.wasm when called

Consumers who never import fastzip/dict never download its module. That is more reliable than hoping the bundler can split a single large binary. The same approach suits separate SIMD and baseline builds selected by feature detection inside the subpath.

What happens when a consumer calls the library The application imports a named function; nothing loads at import time. On the first call, the library starts a single shared initialisation that fetches and compiles the .wasm from a bundler-resolved URL. Later calls reuse the ready engine, and unused subpaths never load. import { compress } no side effects first compress() call starts engine() fetch + compile once URL from import.meta.url convert + call export values in, values out later calls reuse the engine

Step 4 — provide an escape hatch for custom loading

Some consumers need control: loading the .wasm from their own CDN, providing a precompiled WebAssembly.Module (for workers or source-phase imports), running in environments without fetch. Offer an opt-in advanced entry that configures loading before first use:

// src/advanced.js
export { setEngineSource } from "./internal/engine.js";
// usage: setEngineSource(await WebAssembly.compileStreaming(fetch("/static/fastzip.wasm")));

Keep it small and documented as advanced. The main entry remains zero-configuration.

Step 5 — publish types and test as a consumer

Ship hand-written or generated .d.ts files describing the public API only — not the glue’s internals. Then test the packed tarball the way users install it: in a Vite app, a webpack app, Node and Deno, as described in targeting Node and browsers from one Wasm package. Check bundle analysis in each: the .wasm should appear as a separate hashed asset, loaded only by code paths that call the library.

Versioning the public API independently

With the WebAssembly hidden behind a stable API, the library’s semantic version describes the API, not the internals. Rebuilding with a newer Rust, switching from wasm-bindgen to the Component Model, or replacing the algorithm entirely are patch or minor releases if the exports behave the same. Changes that do affect consumers still need care, even when the API is unchanged: a larger binary, new required browser features such as SIMD or threads, or a higher memory peak can break applications with strict budgets or older browser support. Document these in release notes, and consider treating a new required browser feature as a breaking change.

Errors and resources

A clean API also means clean errors and no leaks. Convert traps and error codes into ordinary Error subclasses with stable name and code properties, documented as part of the API. If the library must expose objects that hold WebAssembly memory — a streaming compressor, a parsed document — give them close() and Symbol.dispose methods, register them with a FinalizationRegistry as a safety net, and make every method fail clearly after closing. Consumers then manage those objects like any other resource, with using declarations where available, and never see free() or pointer semantics.

Documenting what the library costs

Consumers choosing a library need numbers the API does not show: the compressed size of each module and when it loads, the browser features it requires, peak memory for typical inputs, and whether it runs work on the calling thread. Put those in the README next to the API. A short table — “core: 140 KB Brotli, loaded on first call; dict: 90 KB, loaded by fastzip/dict; requires WebAssembly SIMD or falls back to a baseline build; peak memory about 2× input size” — lets integrators decide before they install, and holds the library to account when a release changes the numbers. Generate the size figures in CI from the published artefacts so they stay accurate, and mention changes to them in release notes. For heavy operations, say plainly whether calls block the thread and whether a worker-based entry is offered, since that decides how the library fits into an application’s responsiveness budget.

Workers as an implementation detail

Libraries whose operations take more than a few milliseconds should consider running them off the calling thread. The clean-API design makes that possible without changing callers: the public functions are already asynchronous, so the implementation can post work to an internal worker that loads the module, rather than calling it on the main thread. Offer it as an option or a separate subpath — some consumers already run the library inside their own worker and do not want a nested one — and keep the API identical in both modes, as in wrapping a Wasm worker with Comlink.

Expected output

import { compress } from "fastzip" works in Vite, webpack, Node and Deno with no init() call; nothing is fetched until compress is first called; the .wasm appears as a separate hashed asset; unused subpaths are never downloaded; and the published types describe only the public functions.

Gotchas

  • Top-level await in the entry. Importing the package triggers loading. Initialise lazily on first call.
  • Computed .wasm URLs. Bundlers cannot see them. Use a literal new URL("…", import.meta.url).
  • Leaking glue types into the API. Callers depend on internals. Publish types for the public surface only.
  • One huge module for every feature. Consumers pay for all of it. Use subpath exports with separate modules.
  • No escape hatch. Some environments cannot use the default loader. Offer an advanced configuration entry.

Performance note

With lazy initialisation and sideEffects: false, a consumer app that imported the library on every page but called it on one route saved the 310 KB (compressed) module download on 92% of page views. First-call latency was 38 ms on a laptop, after which calls added under 0.1 ms of wrapper overhead.

Download cost on pages that never call the library Kilobytes downloaded on pages that import the library but do not call it, for a package that initialises at import time, and for a side-effect-free package with lazy initialisation. KB downloaded per page view initialise at import 310 KB lazy + side-effect-free 0 KB

Frequently Asked Questions

Should public functions be async even if the export is synchronous? Yes, if they may need to initialise; it also leaves room to move work to a worker later.

Can I offer a synchronous API? Only after explicit initialisation; offer an await ready() plus synchronous functions for performance-sensitive callers.

How do I support CommonJS consumers? Provide a CommonJS entry that exposes the same functions with lazy initialisation, or document dynamic import().

Does tree-shaking remove unused Wasm exports? No — the binary is opaque to bundlers. Split rarely used features into separate modules instead.

Should the library start its own worker? Offer it as an option or separate entry; consumers already running in a worker should be able to call the module directly.

← Back to ESM Bindings & Module Generation