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.
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.
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
causeso 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.
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.
Related
- Awaiting JavaScript promises from Rust — async on the Rust side.
- Emitting ES modules from Emscripten — the singleton loader pattern for C modules.
- Shipping a JavaScript fallback for a Wasm feature — another implementation behind the same API.
- Integrating Wasm into a React app — consuming the wrapper from a framework.
← Back to Async & Event-Loop Integration