Timing Out Slow Wasm Calls
This page answers one task: some inputs make a WebAssembly function run far longer than usual — a pathological regex, a huge file, an infinite loop in untrusted code — and you need a deadline, after which the application gives up, tells the user, and stays usable.
Prerequisites
- [ ] A Wasm function whose run time depends on input.
- [ ] The ability to run it in a Web Worker (or a Node worker thread).
- [ ] A user-facing state for “this is taking too long”.
Why a timeout needs a separate thread
On the main thread, a call into WebAssembly runs to completion. JavaScript timers, promise callbacks and event handlers all wait until it returns, so a
setTimeout intended to enforce a deadline cannot fire while the call is running. Wrapping the call in a promise does not help; the call is still
synchronous. If a function might run for an unbounded time, the main thread is the wrong place for it.
A worker changes the picture. The Wasm call runs on the worker’s thread; the main thread remains free to run its timer, and when the deadline passes it
can stop waiting, update the UI, and — if the call must actually stop — terminate the worker. worker.terminate() stops execution immediately, in the
middle of the Wasm function if necessary, which is the only way to stop a non-cooperative Wasm call in a browser. The cost is that the worker and its
instance are gone; the next request needs a new worker, so keep the compiled module around to make that cheap.
Step 1 — wrap the worker call with a deadline
// main.js
const module = await WebAssembly.compileStreaming(fetch("/engine.wasm"));
let worker = spawn();
function spawn() {
const w = new Worker(new URL("./engine-worker.js", import.meta.url), { type: "module" });
w.postMessage({ type: "init", module }); // WebAssembly.Module is transferable to workers
return w;
}
function call(payload, timeoutMs) {
return new Promise((resolve, reject) => {
const id = crypto.randomUUID();
const timer = setTimeout(() => {
worker.removeEventListener("message", onMessage);
worker.terminate(); // stop the runaway call
worker = spawn();
reject(new DOMException("Wasm call timed out", "TimeoutError"));
}, timeoutMs);
function onMessage(e) {
if (e.data.id !== id) return;
clearTimeout(timer);
worker.removeEventListener("message", onMessage);
e.data.error ? reject(new Error(e.data.error)) : resolve(e.data.result);
}
worker.addEventListener("message", onMessage);
worker.postMessage({ type: "run", id, payload });
});
}
// engine-worker.js
let instance;
self.onmessage = async ({ data }) => {
if (data.type === "init") { instance = await WebAssembly.instantiate(data.module, imports); return; }
try { self.postMessage({ id: data.id, result: run(instance, data.payload) }); }
catch (e) { self.postMessage({ id: data.id, error: String(e) }); }
};
Posting the compiled WebAssembly.Module to each new worker avoids recompiling; instantiation takes milliseconds.
Step 2 — use AbortSignal for composable deadlines
AbortSignal.timeout(ms) creates a signal that aborts after a delay, and AbortSignal.any([...]) combines it with a user’s cancel button. Accept a
signal in the call wrapper so callers compose deadlines and cancellation the same way they do for fetch:
async function callWithSignal(payload, signal) {
if (signal.aborted) throw signal.reason;
return new Promise((resolve, reject) => {
signal.addEventListener("abort", () => { restartWorker(); reject(signal.reason); }, { once: true });
send(payload).then(resolve, reject);
});
}
const signal = AbortSignal.any([AbortSignal.timeout(3000), cancelButton.signal]);
const result = await callWithSignal(input, signal);
Step 3 — add cooperative deadline checks inside Wasm
Terminating the worker loses everything in it — caches, loaded documents, warm state. For long but well-behaved computations, a cooperative check is gentler: the Wasm code periodically checks whether it should stop and returns a partial result or an error. With shared memory, the main thread can set a flag the Wasm loop reads; without it, the Wasm code can compare elapsed time against a deadline passed in:
#[wasm_bindgen]
pub fn solve(input: &[u8], deadline_ms: f64) -> Result<Solution, JsError> {
let mut steps = 0u32;
loop {
// ... one unit of work ...
steps += 1;
if steps % 1024 == 0 && js_sys::Date::now() > deadline_ms {
return Err(JsError::new("deadline exceeded"));
}
}
}
Use cooperative checks for code you control and termination as the backstop for code you do not — untrusted plugins, third-party engines, or loops that might not reach a check.
Step 4 — show the right thing while waiting
A deadline is also a UX decision. Show progress or a spinner after a short delay (300–500 ms), so fast calls do not flash a loading state; offer a cancel button for calls that may take several seconds; and when the deadline passes, say what happened and what the user can do — try a smaller file, simplify the query — rather than showing a generic error. If partial results are useful, show them with a note that the computation was cut short.
Step 5 — choose deadlines from data
Measure the distribution of call durations on real inputs and devices — the 95th and 99th percentiles, not the average — and set the deadline above the 99th percentile on target devices, so normal work never times out and only pathological cases do. Log every timeout with input characteristics (size, type) but not content, to find inputs worth handling differently.
Timeouts in Node and server-side runtimes
In Node, worker_threads support the same pattern, with worker.terminate() returning a promise. Server-side runtimes offer engine-level limits that
browsers do not: Wasmtime’s epoch interruption and fuel let an embedder stop a guest at a deadline without killing a thread, and its async support turns
long-running guest calls into futures that can be dropped. For multi-tenant or plugin hosts, those mechanisms are preferable to thread termination, as
discussed in
sandboxing third-party Wasm plugins.
Timeouts and memory state
A terminated call may have left memory half-updated, but because the whole worker is discarded, nothing from it survives. A cooperative stop, in contrast, leaves the instance alive, so the Wasm code must leave its data structures consistent when it returns early — release partially built structures, roll back changes, or work on a copy and swap only on success. Tests should exercise early returns at many points to catch inconsistent states, which otherwise appear as rare bugs long after the timeout that caused them.
A stop flag in shared memory
When the page is cross-origin isolated, the main thread and the worker can share a small SharedArrayBuffer used purely as a control channel. The main
thread writes 1 to a stop slot with Atomics.store when the deadline passes or the user cancels; the Wasm loop reads the slot every few thousand
iterations with an atomic load and returns early when it is set. Because the check is a single memory read, it can be far more frequent than a clock
check, and it reacts to user cancellation as well as deadlines. With Rust and the atomics target feature, the flag can live in the module’s own shared
memory as an AtomicU32 whose address is exported; without threads in the module, the worker’s JavaScript can still read the shared flag between
chunks of work. Clear the flag before each new request, and treat a stop as a normal result, not an exception, so callers can show partial output.
Monitoring timeouts in production
Timeouts are a symptom worth tracking. Count them per feature and per input size bucket in your telemetry, alongside the duration distribution of successful calls. A rising timeout rate after a release suggests a performance regression; timeouts concentrated in one input type suggest a pathological case worth fixing in the algorithm; timeouts concentrated on low-end devices suggest the deadline is too tight for them, or that the work should be reduced for those devices. Without these numbers, timeouts look like random user complaints.
Expected output
Normal inputs complete in under 400 ms; a pathological input hits the 3-second deadline, the UI shows “This file is too complex to preview”, the worker is terminated and replaced in 6 ms from the cached module, and the next request succeeds; the cancel button aborts through the same signal path.
Gotchas
- Timeouts on the main thread. Timers cannot fire during a Wasm call. Use a worker.
- Recompiling after termination. Slow. Cache the compiled
WebAssembly.Module. - Deadlines from averages. Normal users hit them. Use high percentiles.
- Cooperative checks only. Code that never reaches a check runs forever. Keep termination as a backstop.
- Inconsistent state after early return. Leave data structures valid when stopping cooperatively.
Performance note
Recreating the worker from a cached compiled module took 6 ms, compared with 140 ms to fetch and compile again; cooperative checks every 1,024 steps added 0.4% to normal run time.
Frequently Asked Questions
Can I interrupt Wasm on the main thread? No — move the call to a worker if it needs a deadline.
Does worker.terminate() free memory?
Yes, the worker’s instance and memory are released.
Can JSPI help with timeouts? JSPI suspends Wasm only at calls to async imports; it does not interrupt loops.
What about shared-memory workers? Terminating one thread of a shared-memory pool may leave locks held; restart the whole pool.
How can the main thread tell a running Wasm loop to stop without terminating it? Share a small SharedArrayBuffer as a stop flag and have the loop check it with an atomic load every few thousand iterations.
Related
- Cancelling long-running Wasm work — cooperative cancellation.
- Wrapping a Wasm worker with Comlink — worker APIs.
- Recovering a module after a trap — recreating instances.
- Debouncing Wasm calls from user input — fewer calls to time out.
← Back to Async & Event-Loop Integration