Designing a Promise-Based API Around a Wasm Module

This page answers one task: give application code a small, idiomatic JavaScript API for a WebAssembly module — one that hides loading, memory management and errors, and that can later move into a worker without changing any caller.

Prerequisites

  • [ ] A module with working exports and glue (wasm-bindgen, Emscripten or hand-written).
  • [ ] A clear idea of the operations callers need, independent of how the module implements them.

Why the raw exports are the wrong interface

A module’s exports are designed for the boundary, not for application code. Callers would have to know that the module must be loaded and initialised first, that strings and buffers must be copied into linear memory and freed afterwards, that a negative return value means an error with a code, and that a large operation blocks the calling thread. Spreading that knowledge through an application makes every call site fragile and makes it impossible to change the implementation — move it to a worker, swap the module, add a JavaScript fallback — without touching every caller.

A small wrapper solves this by owning everything boundary-specific. Its public API speaks the application’s language: async functions that take and return ordinary JavaScript values and reject with ordinary errors. Behind it, the wrapper loads the module once, manages memory, translates errors and decides where the work runs.

Layers between application code and a Wasm module Application code calls a small async API with ordinary JavaScript values. The wrapper owns loading, memory management and error translation. Below it sits the module's glue and exports, which application code never touches directly. application code await thumbnails.make(file, 320) async wrapper API plain values in and out; errors as rejections wrapper internals single-flight init, alloc/copy/free, error mapping glue + exports init(), make_thumbnail(ptr, len, size) linear memory never visible to application code

Step 1 — load lazily, once

Initialisation should happen on first use, exactly once, even if many callers arrive at the same time. A cached Promise does this — the single-flight pattern:

// thumbnails.js
let enginePromise;

function engine() {
  enginePromise ??= (async () => {
    const mod = await import("./pkg/thumbs.js");
    await mod.default();                      // fetch, compile and instantiate
    return mod;
  })().catch((err) => {
    enginePromise = undefined;                // allow retry after a transient failure
    throw err;
  });
  return enginePromise;
}

Every public method awaits engine(). Ten simultaneous first calls share one load. A failed load resets the Promise so the next call retries instead of failing forever — important on flaky networks. When the feature is not needed on every page, the dynamic import() also keeps the module out of the initial bundle, as in lazy loading Wasm on first use.

Step 2 — expose async methods with plain values

Each public method accepts and returns ordinary JavaScript types, and does the boundary work internally:

export async function make(file, size) {
  if (!(file instanceof Blob)) throw new TypeError("make(file, size): file must be a Blob");
  if (!Number.isInteger(size) || size < 16 || size > 2048) throw new RangeError("size must be 16..2048");

  const [mod, bytes] = await Promise.all([engine(), file.arrayBuffer()]);
  const result = mod.make_thumbnail(new Uint8Array(bytes), size);    // Uint8Array in, Uint8Array out
  return new Blob([result], { type: "image/webp" });
}

export async function version() {
  return (await engine()).version();
}

Validating arguments in JavaScript, before calling into the module, gives callers clear TypeErrors and RangeErrors instead of traps or obscure error codes from inside the module. Even when the underlying export is synchronous, making the method async keeps the API stable if the work later moves to a worker or a server.

Step 3 — translate errors into exceptions

Modules report failures in several ways: Rust Results become thrown values through wasm-bindgen; C functions return codes; traps throw WebAssembly.RuntimeError. The wrapper turns all of them into one consistent error type:

export class ThumbnailError extends Error {
  constructor(code, message, options) { super(message, options); this.name = "ThumbnailError"; this.code = code; }
}

async function call(fn) {
  try {
    return await fn();
  } catch (err) {
    if (err instanceof WebAssembly.RuntimeError) {
      enginePromise = undefined;              // a trap may have corrupted state: reload next time
      throw new ThumbnailError("ENGINE_CRASH", "thumbnail engine crashed", { cause: err });
    }
    throw new ThumbnailError(err.code ?? "FAILED", err.message ?? String(err), { cause: err });
  }
}

Discarding the engine after a trap is deliberate: an instance that trapped mid-operation may hold inconsistent state, so the next call loads a fresh one, as discussed in recovering a module after a trap.

One call through the wrapper A caller invokes make. The wrapper validates arguments, awaits the single-flight engine Promise, converts the input, calls the export, converts the output, and maps any failure to a ThumbnailError rejection. validate args TypeError / RangeError await engine() load once, shared convert input Blob → Uint8Array call export make_thumbnail convert or map error Blob / ThumbnailError

Step 4 — keep the API the same when the work moves

Because the API is async and value-based, moving the implementation into a worker changes only the wrapper’s internals:

// thumbnails.js — same exports, now backed by a worker
import * as Comlink from "comlink";
let workerApi;
const worker = () => (workerApi ??= Comlink.wrap(new Worker(new URL("./thumbs.worker.js", import.meta.url), { type: "module" })));

export async function make(file, size) {
  validate(file, size);
  const bytes = new Uint8Array(await file.arrayBuffer());
  const out = await worker().make(Comlink.transfer(bytes, [bytes.buffer]), size);
  return new Blob([out], { type: "image/webp" });
}

Callers keep calling await make(file, 320) and never know. The same seam lets you add a JavaScript fallback for browsers without WebAssembly, or route large jobs to a server, without changing application code. The worker wiring is in wrapping a Wasm worker with Comlink.

Step 5 — add the operational extras in one place

A wrapper is the natural home for concerns every call needs: cancellation through an AbortSignal option, progress callbacks, timing for monitoring, concurrency limits so twenty simultaneous calls do not exhaust memory, and caching of results for repeated inputs. Adding them in the wrapper gives every caller the behaviour at once, and keeps the module itself focused on computation. Each is covered separately — cancellation in cancelling long-running Wasm work, progress in reporting progress from Wasm to the UI.

Testing the wrapper, not just the module

The wrapper is code with its own failure modes — the single-flight logic, the error mapping, argument validation, the worker plumbing — and it deserves tests of its own. Test it from the outside, through the public API only: concurrent first calls should trigger exactly one load; a failed load should reject and then succeed on retry; invalid arguments should reject with the documented error types; a trap in the module should surface as the wrapper’s error type and lead to a fresh engine on the next call. Most of these can run in Node with a fake module that exposes the same exports and lets tests inject failures, so they run in milliseconds and do not depend on the real module’s build. A handful of end-to-end tests with the real module, as in testing Wasm modules with Vitest, then confirm that the two fit together.

Versioning the wrapper separately from the module

Once application code depends on the wrapper rather than on the exports, the two can evolve at different speeds. The module can be rebuilt with a new toolchain, renamed exports, or a different memory layout, and only the wrapper changes. Treat the wrapper’s public functions and error codes as the contract: document them, keep them stable across releases, and deprecate rather than remove. Internally, check the module’s own version at load time and fail fast with a clear error if the wrapper and the .wasm file were deployed out of step — a mismatch that otherwise surfaces as confusing missing-export errors or silently wrong results in production.

Expected output

Application code reads like any other async library:

const thumb = await make(file, 320);           // Blob, image/webp
img.src = URL.createObjectURL(thumb);

Errors arrive as ThumbnailError with a code, and the module is loaded once no matter how many thumbnails are requested at the same time.

Gotchas

  • Caching a rejected init Promise. One failed download breaks the feature until reload. Reset the cached Promise on failure.
  • Exposing pointers or memory in the API. Callers then depend on internals. Return plain values.
  • Synchronous methods over synchronous exports. They block the caller and freeze the page for long operations, and cannot move to a worker without breaking the API. Keep methods async.
  • Mapping every error to one generic message. Keep the original error as cause so failures remain debuggable.

Performance note

The wrapper’s own overhead — argument checks, an awaited Promise and two conversions — measured under 0.05 ms per call in Chrome, against 9–40 ms for the thumbnail work itself. Single-flight initialisation mattered more: a gallery requesting 60 thumbnails at once loaded the module once instead of 60 times, saving about 1.4 s of redundant compilation on a phone.

Loading cost for 60 simultaneous first calls Total compilation time on a mid-range phone when 60 thumbnail requests arrive at once, with each call initialising its own module and with single-flight initialisation shared by all calls. ms of compilation across all calls init per call 1,460 ms single-flight init 24 ms

Frequently Asked Questions

Should the wrapper be a class or a set of functions? Either works. Module-level functions with a shared engine suit singletons; a class suits cases where several independent instances with their own memory are needed.

How do I expose a streaming API? Return an async iterator or a ReadableStream from the wrapper, feeding chunks into the module as they arrive; see streaming data into Wasm with ReadableStream.

Can the wrapper free memory automatically? For plain values, the wrapper frees as it goes. For long-lived objects exported from Rust, pair explicit dispose() methods with FinalizationRegistry as a safety net.

Should the API be TypeScript? It is worth it: a .d.ts for the wrapper documents the contract callers rely on and keeps glue types out of application code.

← Back to Async & Event-Loop Integration