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 SharedArrayBuffer shared 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.

Ways for the main thread to wait on a worker Polling checks shared memory repeatedly and either wastes work or adds latency. postMessage works everywhere but needs a second channel and event-loop delivery. Atomics.waitAsync resolves a promise when the worker calls Atomics.notify on the shared location. polling check value each frame or timer latency up to the poll interval work even when nothing changes avoid postMessage worker posts after updating memory works without isolation second channel to keep in sync fallback Atomics.waitAsync promise resolves on notify no polling, no extra messages main-thread safe preferred when isolated

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".

Waiting for a worker signal without blocking The main thread reads the counter and calls Atomics.waitAsync with that value. The page keeps running. The worker finishes a frame, increments the counter and calls Atomics.notify, which resolves the main thread's promise; the main thread presents the frame and waits again. main thread shared memory worker seen = load(signal) waitAsync(signal, 0, seen) → promise Atomics.add(signal, 1) Atomics.notify promise resolves "ok"

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.wait on the main thread. It throws. Use waitAsync.
  • Forgetting the synchronous result. async: false means 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-Int32Array views. waitAsync requires an Int32Array or BigInt64Array on 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.

Latency from worker signal to main-thread reaction Milliseconds between a worker signalling completion and the main thread reacting, using Atomics.waitAsync, postMessage, and polling shared memory once per animation frame. ms from signal to reaction (median) Atomics.waitAsync 0.1 ms postMessage 0.3 ms poll each animation frame 8.3 ms

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.

← Back to SharedArrayBuffer, Atomics & Threading