Instantiating Small Modules Synchronously
This page answers one question: can you compile and instantiate a WebAssembly module synchronously — without await — and when is that
allowed, sensible, or a mistake?
Prerequisites
- [ ] A module’s bytes available synchronously: inlined in JavaScript, already fetched, or read from disk in Node.
- [ ] Knowledge of where the code runs: the browser main thread, a worker, or a server-side runtime.
Two APIs, one restricted
The JavaScript API has asynchronous functions — WebAssembly.compile, instantiate, compileStreaming, instantiateStreaming — that
return Promises, and synchronous constructors: new WebAssembly.Module(bytes) compiles, new WebAssembly.Instance(module, imports)
instantiates. The synchronous forms block the calling thread until the work is done.
On a worker thread or in Node, blocking is often fine. On the browser’s main thread, it is not: compiling a megabyte of WebAssembly can take
tens to hundreds of milliseconds on a phone, during which the page cannot respond to input, animate or paint. To prevent that, Chromium-based
browsers limit synchronous compilation on the main thread to small modules — 4 KB of bytes — and throw a RangeError for anything larger.
Other engines do not enforce the limit but the reasoning applies everywhere: the asynchronous API lets the engine compile on background threads
and stream, the synchronous one cannot.
Step 1 — use it for tiny inline modules
The legitimate main-thread use is a tiny module that must be available immediately, without making surrounding code async — a feature probe, a small checksum or hashing routine, a bit-manipulation helper:
// a 41-byte module exporting add(i32, i32) -> i32
const ADD_WASM = new Uint8Array([
0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00, 0x01, 0x07, 0x01, 0x60, 0x02, 0x7f, 0x7f, 0x01,
0x7f, 0x03, 0x02, 0x01, 0x00, 0x07, 0x07, 0x01, 0x03, 0x61, 0x64, 0x64, 0x00, 0x00, 0x0a, 0x09,
0x01, 0x07, 0x00, 0x20, 0x00, 0x20, 0x01, 0x6a, 0x0b,
]);
const addModule = new WebAssembly.Module(ADD_WASM);
const { add } = new WebAssembly.Instance(addModule, {}).exports;
console.log(add(2, 40)); // 42 — available synchronously, at module evaluation time
Feature probes are the most common case: compile a few bytes that use a proposal and see whether it throws. WebAssembly.validate is the
cheaper tool when you only need a yes or no, as in
detecting proposal support at runtime.
Step 2 — see the limit in action
const big = new Uint8Array(await (await fetch("/wasm/app.wasm")).arrayBuffer()); // 1.2 MB
try {
new WebAssembly.Module(big);
} catch (err) {
console.log(err);
}
RangeError: WebAssembly.Module(): Buffer size is larger than 4KB. Use WebAssembly.compile, or compile on a worker thread.
The message names both alternatives. Synchronous new WebAssembly.Instance on an already compiled module is also limited on the main thread in
Chromium for modules above the threshold, because instantiation can trigger work proportional to module size.
Step 3 — compile synchronously inside a worker
Inside a worker, blocking only that worker’s thread is fine, and synchronous APIs simplify code that must have the module before it can do
anything — such as a worker whose onmessage handler is synchronous:
// worker.js — bytes posted from the main thread, compiled synchronously here
let exports;
self.onmessage = ({ data }) => {
if (data.type === "init") {
const module = new WebAssembly.Module(data.bytes); // blocks only this worker
exports = new WebAssembly.Instance(module, makeImports()).exports;
return;
}
self.postMessage(exports.process(data.payload));
};
Even here, prefer posting an already compiled WebAssembly.Module from the main thread when several workers need the same code, so it is compiled
once rather than once per worker — the pattern in
instantiating one module many times.
Step 4 — use it freely on the server
In Node, Deno and Bun, synchronous compilation is common and fine for startup code, command-line tools and tests:
// Node CommonJS-style or ESM top level
import { readFileSync } from "node:fs";
const module = new WebAssembly.Module(readFileSync(new URL("./tool.wasm", import.meta.url)));
const instance = new WebAssembly.Instance(module, {});
The trade-off is the same as for any synchronous I/O in a server: fine at startup, wrong in a request handler that runs while other requests
wait. Compile at startup and reuse the Module; the loading options are covered in
loading Wasm in Node.js with ES modules.
Step 5 — prefer async everywhere else
For any module of real size in a browser, use the asynchronous APIs, and streaming where possible. instantiateStreaming compiles while
downloading on background threads, uses the compiled-code cache, and never blocks the page. The synchronous constructors give up all three. The
right mental model is that synchronous compilation is a convenience for tiny modules and for contexts where blocking is harmless, not a simpler
alternative to promises.
Keeping inline modules small on purpose
If a page relies on synchronous main-thread compilation, the 4 KB limit becomes a design constraint, and it is worth treating it like one. Build
inline helper modules with the smallest settings — freestanding C with no libc, or #![no_std] Rust with no allocator — so their size is
dominated by the code you wrote. Keep their interface numeric so no glue is needed. And add a build step that fails if the module exceeds a
budget comfortably below the limit, such as 3 KB, so a future change cannot silently push it over and turn a working page into one that throws on
load in Chromium only.
When a helper outgrows the limit, do not reach for workarounds. Make the calling code asynchronous — usually a matter of awaiting a single loader promise during startup — or move the work into a worker. Both are small changes compared with debugging a page that works in one browser and fails in another. The techniques for producing modules this small are in building a Wasm module without libc, and inlining them is covered in inlining small Wasm modules as Base64.
What happens inside a synchronous compile
The two APIs differ in more than whether they return a Promise. An asynchronous compile lets the engine split the module across background
threads, compile functions in parallel with baseline and optimizing tiers, overlap compilation with the download, and consult the
compiled-code cache. A synchronous compile must finish everything before returning, on the calling thread; engines typically compile all
functions with the baseline tier immediately to get there quickly and optimize later, and some cannot use their code cache on this path. That is
why even in a worker, a large module compiled synchronously can be noticeably slower to first result than the same module compiled with
WebAssembly.compile — the parallelism is lost.
Expected output
On the main thread in Chrome, the 41-byte module compiles synchronously and add(2, 40) returns 42; the 1.2 MB module throws the 4 KB
RangeError. In a worker, both compile synchronously.
Gotchas
- Works in Firefox, throws in Chrome. Only Chromium enforces the 4 KB main-thread limit. Test in Chromium or avoid the synchronous path.
- Inlined module grows past 4 KB. A helper that started tiny can cross the limit after a change. Add a size assertion in the build.
- Blocking a worker that also handles messages. A long synchronous compile delays every message to that worker. Compile once at startup.
- Top-level synchronous compile in a shared module. A module imported by both the page and workers compiles synchronously in every context. Make the compile lazy, or async in the page.
- No compiled-code caching. Synchronous compiles may skip the engine’s code cache. Prefer async for anything compiled on every page load.
Performance note
A 1.2 MB module compiled in 48 ms with WebAssembly.compile (baseline tier on background threads, page responsive) and in 112 ms with
new WebAssembly.Module inside a worker on the same laptop — the synchronous path could not use parallel compilation. For the 41-byte helper,
both took a few microseconds.
Frequently Asked Questions
Is the 4 KB limit configurable? Not for pages. It is a browser policy for the main thread; workers are not limited.
Does WebAssembly.validate have the same limit?
No. Validation is cheaper than compilation and allowed synchronously for any size, though it still blocks for large modules.
What about inlined modules with SINGLE_FILE builds?
Emscripten’s single-file output decodes base64 and compiles asynchronously, so the limit does not apply. Hand-rolled inlining should do the same
for anything over 4 KB; see inlining small Wasm modules as Base64.
Is there a synchronous streaming API? No. Streaming compilation is inherently asynchronous — bytes arrive over time — so it exists only in the Promise-based API.
Do service workers count as the main thread? No — service workers are workers, but they should stay responsive to fetch events, so keep synchronous compilation there short.
Related
- Streaming instantiation vs ArrayBuffer instantiation — the preferred async paths.
- Compiling Wasm in a worker to free the main thread — moving compilation off the main thread.
- Feature detecting Wasm at startup — tiny synchronous probes in practice.
- Building a Wasm module without libc — producing modules small enough to inline.
← Back to Wasm Instantiation Lifecycle