Cancelling Long-Running Wasm Work
This page answers one task: the user starts a long WebAssembly operation — an export, a render, a search — then changes their mind, and you need to stop it promptly without leaving the page or the module in a broken state.
Prerequisites
- [ ] The long-running work in a Web Worker, as in wrapping a Wasm worker with Comlink.
- [ ] The ability to change the module’s code to check for cancellation — or, failing that, to terminate the worker.
- [ ] For the shared-flag technique, a cross-origin isolated page (for
SharedArrayBuffer).
Why a running Wasm call cannot be interrupted
A WebAssembly function runs to completion on its thread. While it runs, that thread’s event loop does not turn: no messages are delivered, no
timers fire, no AbortSignal listener runs. Calling controller.abort() on the main thread sets a flag on an object that the worker cannot even
see until the Wasm call returns. There is no instruction to interrupt a running function from outside, short of killing the thread.
So cancellation has to be designed in, and there are three ways to do it, from most graceful to most brutal. The module can periodically check a flag that the main thread can set — cooperative cancellation through shared memory. The work can be split into chunks, with the worker’s JavaScript checking for cancellation between them — chunked cancellation. Or the main thread can terminate the worker and start a fresh one — termination. The right choice depends on how much you control the Wasm code and how quickly cancellation must take effect.
Step 1 — cooperative cancellation with a shared flag
Allocate a small SharedArrayBuffer that both threads can see, pass it to the worker, and have the module check it inside its long loops:
// main thread
const cancelFlag = new Int32Array(new SharedArrayBuffer(4));
worker.postMessage({ type: "start", cancelFlag, input }, [input.buffer]);
cancelButton.onclick = () => Atomics.store(cancelFlag, 0, 1);
The worker hands the flag to the module through an import that reads it — or, for a threaded module with shared memory, places the flag in linear memory directly:
// worker
let flag;
const imports = { env: { should_cancel: () => Atomics.load(flag, 0) } };
self.onmessage = ({ data }) => {
flag = data.cancelFlag;
const status = exports.render(ptrFor(data.input), data.input.length); // returns 1 if cancelled
self.postMessage({ status });
};
// in the module: check every N iterations, not every iteration
for (i, tile) in tiles.iter_mut().enumerate() {
if i % 64 == 0 && unsafe { should_cancel() } != 0 {
return Status::Cancelled; // unwind normally, freeing what was allocated
}
render_tile(tile);
}
Checking every iteration would add a boundary call per iteration; checking every few dozen keeps the overhead negligible while cancelling within milliseconds. Because the module returns normally, its own cleanup — freeing buffers, releasing locks — runs as usual. The shared-memory rules are covered in using Atomics for Wasm thread synchronization.
Step 2 — chunked cancellation with AbortSignal
When shared memory is unavailable, split the work so the worker’s JavaScript regains control between pieces, and check for cancellation there:
// worker
let cancelled = false;
self.onmessage = async ({ data }) => {
if (data.type === "cancel") { cancelled = true; return; }
cancelled = false;
const job = exports.start_job(ptrFor(data.input), data.input.length);
while (!exports.job_done(job)) {
if (cancelled) { exports.abort_job(job); self.postMessage({ status: "cancelled" }); return; }
exports.job_step(job, 50); // process 50 units, then return
await new Promise((r) => setTimeout(r, 0)); // yield so the cancel message can arrive
}
self.postMessage({ status: "done", result: exports.job_result(job) });
};
The module needs a stepwise API — start, step, done, abort — rather than one long call. The yield between steps lets the worker’s event loop deliver the cancel message. Choose the step size so each step takes a few milliseconds: shorter steps cancel faster; longer steps waste less time yielding.
Step 3 — terminate the worker when nothing else works
If the Wasm code cannot be changed — a third-party library, a WASI program that runs to completion — terminate the worker:
function cancelHard() {
worker.terminate(); // stops immediately, mid-instruction
worker = createWorker(); // new worker, new instance, fresh memory
}
Termination is immediate and always works, but everything in the worker is gone: the module instance, its memory, any cached state. Recreating the worker costs its startup time — tens of milliseconds, mostly instantiation if the compiled module is reused. Keep a pre-started spare worker if cancellations are frequent and the restart delay matters.
Step 4 — clean up consistently
Whichever technique you use, make the cancelled state well defined. With cooperative cancellation, the module should free everything it allocated for the job before returning — design the job’s data so that dropping it releases everything. With chunked cancellation, call an explicit abort function. With termination, there is nothing to clean up inside the worker, but the main thread should discard any partial results and any references to buffers that were transferred to the worker.
Wiring cancellation to AbortController
Whatever technique the worker uses internally, expose cancellation to the rest of the application through the standard AbortController and
AbortSignal, so it composes with fetch, timeouts and framework conventions:
export function render(input, { signal } = {}) {
return new Promise((resolve, reject) => {
const flag = new Int32Array(new SharedArrayBuffer(4));
const onAbort = () => Atomics.store(flag, 0, 1);
signal?.addEventListener("abort", onAbort, { once: true });
worker.onmessage = ({ data }) => {
signal?.removeEventListener("abort", onAbort);
data.status === 1 ? reject(new DOMException("render cancelled", "AbortError")) : resolve(data.result);
};
worker.postMessage({ type: "start", cancelFlag: flag, input });
});
}
const controller = new AbortController();
setTimeout(() => controller.abort(), 5000); // a timeout is just another abort
await render(bytes, { signal: controller.signal });
Rejecting with an AbortError lets callers distinguish cancellation from failure with err.name === "AbortError", the same check they already use for
aborted fetches. A timeout, a navigation away from the view, or a newer request superseding an older one all become the same mechanism.
Designing cancellable Wasm APIs
The common thread is that cancellation is an API design question. Long-running exports that cannot be stopped force callers into termination, which
throws away valuable state. An API that either checks a cancellation source or works in steps gives callers the choice. A good shape for long operations
is a job object: start returns a handle, step makes bounded progress, status reports progress and completion, result retrieves the output and
abort frees everything. That shape supports cancellation, progress reporting and time-slicing on the main thread with one design, and it maps cleanly
onto Promise-based wrappers, as in
designing a promise-based API around a Wasm module.
Expected output
With the shared-flag approach, pressing cancel during a long render stops it within a few milliseconds; the worker reports status: 1 (cancelled), and a
second render can start immediately in the same worker without re-instantiation.
Gotchas
- Calling
abort()and expecting the Wasm call to stop. The worker’s event loop is blocked; the signal is not observed until the call returns. - Checking the flag every iteration. Each check is a boundary call or atomic load; check every N iterations instead.
- Leaking memory on cancellation. Early returns that skip frees. Structure cleanup so it runs on every exit path.
- Reusing a flag across jobs without resetting it. A flag left at 1 cancels the next job immediately. Reset it, or use a fresh one per job.
- SharedArrayBuffer unavailable. The page is not cross-origin isolated. Use chunked cancellation or termination instead.
Performance note
Checking a shared flag every 64 tiles added under 0.5% to a render’s run time and cancelled within 4 ms. Chunked cancellation with 5 ms steps added about 3% from yielding and cancelled within 6 ms. Terminating and recreating the worker cancelled instantly but cost 38 ms before the next job could start.
Frequently Asked Questions
Can JSPI help with cancellation? JSPI lets Wasm suspend on async JavaScript, which creates natural points to check for cancellation; see calling async JavaScript with JSPI.
What about work on the main thread? The same chunking approach applies — work in steps and yield to the event loop — but heavy work should move to a worker first.
Can server runtimes cancel running Wasm? Yes — wasmtime’s epoch interruption and fuel can stop a guest from the host; see limiting plugin CPU and memory use.
How often should the module check for cancellation? Aim for a check every millisecond or two of work. That bounds cancellation latency to a frame or less while keeping the checking overhead well under one percent.
What if a cancelled job had already produced partial output? Discard it unless the API explicitly supports partial results. Returning half a result as if it were complete is a bug that is hard to trace later.
Does worker.terminate() leak memory?
No. Terminating the worker releases its instance and memory once nothing else references them.
Related
- Keeping the UI responsive during long Wasm tasks — the companion problem.
- Reporting progress from Wasm to the UI — progress through the same shared memory.
- Detecting cross-origin isolation at runtime — choosing the cancellation technique.
- Recovering a module after a trap — another reason to recreate instances.
← Back to Async & Event-Loop Integration