Using Atomics.waitAsync on the Main Thread
This page answers one task: the main thread needs to know when a WebAssembly worker has finished something — a frame rendered, a job done, data available in a ring buffer — communicated through shared memory, without blocking the page and without polling.
Prerequisites
- [ ] A cross-origin isolated page with a
SharedArrayBuffershared between the main thread and a worker. - [ ] A worker that updates a value in shared memory when its state changes.
- [ ] Familiarity with using Atomics for Wasm thread synchronization.
Why the main thread cannot simply wait
Inside workers, Atomics.wait(int32Array, index, expected) puts the thread to sleep until another thread calls Atomics.notify on the same location —
the efficient way to wait for a signal. On the main thread, Atomics.wait throws a TypeError: blocking the thread that runs the page’s rendering
and input handling is not allowed. Before waitAsync, the main thread had two poor options: poll the shared value on a timer or every animation
frame, wasting work and adding latency, or ask the worker to also send a postMessage, duplicating the signalling path.
Atomics.waitAsync gives the main thread the same primitive in non-blocking form. It returns an object whose value is a Promise that resolves when the
location is notified (or a timeout passes). The main thread keeps running, and its continuation runs as soon as the worker signals — with no polling
and no extra messages.
Step 1 — agree on a signal location
Reserve an Int32Array slot in shared memory as the signal. A sequence counter works better than a boolean, because the waiter can tell whether
anything happened since it last looked:
// shared between main thread and worker
const sab = new SharedArrayBuffer(4);
const signal = new Int32Array(sab); // signal[0]: increments each time a frame is ready
worker.postMessage({ type: "init", sab });
The worker, after finishing each frame, increments the counter and notifies:
// worker
Atomics.add(signal, 0, 1);
Atomics.notify(signal, 0); // wakes any waiters on signal[0]
If the worker’s Wasm module uses shared memory itself, the signal can live inside linear memory, and the module can increment and notify with the
memory.atomic.notify instruction (core::arch::wasm32::memory_atomic_notify in Rust) — no JavaScript call needed on the worker side.
Step 2 — wait asynchronously on the main thread
async function waitForChange(signal, seen, timeoutMs = Infinity) {
const result = Atomics.waitAsync(signal, 0, seen, timeoutMs);
if (!result.async) return result.value; // "not-equal" (already changed) or "timed-out"
return await result.value; // "ok" when notified, or "timed-out"
}
let seen = Atomics.load(signal, 0);
for (;;) {
const r = await waitForChange(signal, seen, 1000);
if (r === "timed-out") { checkWorkerHealth(); continue; }
seen = Atomics.load(signal, 0);
presentFrame();
}
waitAsync compares the location with expected first. If the value already differs, it returns synchronously with async: false and value: "not-equal", so a change that happened just before the call is never missed. Otherwise the Promise settles with "ok" on notify or "timed-out".
Step 3 — always re-check after waking
A resolved Promise means “notified or timed out”, not “the condition you care about is true”. Several changes can happen before the continuation runs, and a notify may be for a different reason than expected. Treat waking as a hint: reload the shared state, decide what to do, and wait again with the latest value. The counter pattern handles this naturally — compare the new count with the last one you processed, and handle every increment if each represents work.
Step 4 — fall back where waitAsync is missing
Atomics.waitAsync is supported in Chromium-based browsers and Safari, and is arriving in Firefox; check before use. The simplest fallback is to have
the worker also post a lightweight message, and to treat either signal as a wake-up:
const hasWaitAsync = typeof Atomics.waitAsync === "function";
function onWorkerSignal(callback) {
if (hasWaitAsync) {
(async () => {
let seen = Atomics.load(signal, 0);
for (;;) { await waitForChange(signal, seen); seen = Atomics.load(signal, 0); callback(); }
})();
} else {
worker.addEventListener("message", (e) => { if (e.data === "signal") callback(); });
}
}
The worker then posts "signal" only when told to, keeping the cost off the common path. A polyfill based on a helper worker that calls the blocking
Atomics.wait and posts back also exists, at the cost of an extra thread.
Step 5 — avoid leaking waiters
Each pending waitAsync holds a small record in the engine until it resolves. Loops that call waitAsync with an infinite timeout on locations that are
never notified — for example after the worker is terminated — accumulate pending Promises. Use a finite timeout and stop the loop when the worker shuts
down, or notify the location one final time during shutdown so every waiter resolves and exits.
Using waitAsync with Wasm-side locks
The same primitive helps when the main thread must interact with a lock or condition in a threaded module’s memory. A module built with threads can
use memory.atomic.wait32 inside workers, but its exports must never block on the main thread — Emscripten, for example, busy-waits for main-thread
locks, which freezes the page if a worker holds the lock for long. A better design keeps the main thread out of lock contention entirely: workers
publish results into shared memory and signal; the main thread awaits the signal with waitAsync and then reads the published state, which no worker is
modifying any more. That turns a potential freeze into an ordinary asynchronous wait. Emscripten itself uses waitAsync, where available, to implement
emscripten_atomic_wait_async and to make certain proxied calls non-blocking on the main thread, so a threaded C++ application can wait for worker
results without busy-waiting. The pattern generalises: whatever the language, the main thread should await, and only workers should block.
Combining waitAsync with rendering
A frequent use is presenting frames rendered by a worker. Waking on every notify and drawing immediately can draw more often than the display refreshes,
and can draw in the middle of a frame’s layout work. A better pattern separates the two: the waitAsync loop only records that a new frame is ready —
storing the counter value — and a requestAnimationFrame callback draws the latest ready frame once per display refresh. If the worker produces frames
faster than the display, intermediate frames are skipped without effort; if slower, the animation callback simply finds nothing new and does nothing.
The worker should write each frame into one of two or three buffers in shared memory and publish the index of the completed buffer as part of the
signal, so it never overwrites the buffer the main thread is currently reading. With that triple-buffering, the main thread reads a complete frame, the
worker keeps rendering into another buffer, and neither side ever waits for the other — the same structure graphics drivers use for swap chains.
Expected output
The main thread presents each frame within about 0.1 ms of the worker notifying, measured with performance.now() on both sides; no long tasks appear in
the Performance panel; and the page works, with slightly higher latency, in a browser without waitAsync via the message fallback.
Gotchas
- Calling
Atomics.waiton the main thread. It throws. UsewaitAsync. - Forgetting the synchronous result.
async: falsemeans the value already changed; handle it without awaiting. - Notify without a change. If the worker notifies before updating the value, the waiter may see the old value and wait again. Update, then notify.
- Non-
Int32Arrayviews.waitAsyncrequires anInt32ArrayorBigInt64Arrayon shared memory. - Waiting forever on a terminated worker. Use timeouts or a final notify on shutdown.
Performance note
From the worker’s Atomics.notify to the main thread’s continuation took about 0.05–0.15 ms in Chrome when the main thread was idle, against 0.2–0.5 ms
for a postMessage and up to 16.7 ms when polling once per animation frame.
Frequently Asked Questions
Can waitAsync be used in workers too?
Yes, and it is useful there when a worker must keep its event loop running while waiting — for example, to keep receiving messages.
Does waitAsync work on non-shared memory?
No. It requires a view over a SharedArrayBuffer.
How many waiters can one notify wake?
Atomics.notify takes a count; the default wakes all waiters on that location.
Is there a Wasm instruction equivalent?
Not for async waiting. memory.atomic.wait32 blocks, so it is only for workers.
Is waitAsync affected by background-tab throttling?
Promise continuations still run, but the page’s other work is throttled. Pair it with requestAnimationFrame, which pauses in background tabs.
Can I cancel a pending waitAsync?
There is no cancel method. Notify the location, or let a finite timeout expire, and ignore the result.
Related
- Implementing a lock-free ring buffer in shared memory — signalling data availability.
- Building a Wasm thread pool — signalling job completion.
- Keeping the UI responsive during long Wasm tasks — the main-thread context.
- Porting pthreads code with Emscripten — main-thread blocking rules.
← Back to SharedArrayBuffer, Atomics & Threading