Atomics.waitAsync(): Non-Blocking Synchronization Between Web Workers
AI generated
JS
() =>
JavaScript · Web Workers · Concurrency
Atomics.waitAsync()
Synchronizing web workers over SharedArrayBuffer without freezing the thread

Multiple web workers sharing the same SharedArrayBuffer need a way to wait on each other without constantly polling in a loop. Atomics.wait() provides exactly that, but blocks the entire thread and is therefore forbidden on the main thread. Atomics.waitAsync() solves the same problem non-blockingly, working even where blocking simply isn't an option.

14 min read SharedArrayBuffer · Atomics.notify Wait-async instead of busy-waiting

1. The coordination problem between multiple web workers

As soon as multiple web workers read and write the same memory region, the same fundamental problem arises as in any classic multithreading environment: without coordination, one worker can read a value while another is right in the middle of updating it, leading to inconsistent or unusable intermediate states. Plain postMessage communication only solves this partially, because every message has to be copied or at least serialized, making it too slow for fine-grained, frequent synchronization.

For this, JavaScript offers SharedArrayBuffer together with the Atomics operations, genuine memory-backed synchronization primitives similar to those known from languages with direct thread access. They let you build mutexes, semaphores, or simple signaling flags that coordinate multiple workers without having to send a full message over the message channel on every update.

2. Foundation: SharedArrayBuffer and cross-origin isolation

A SharedArrayBuffer behaves similarly to a regular ArrayBuffer, but when passed to a worker via postMessage it is not copied, it is genuinely shared: both sides access the same physical memory region. Usually an Int32Array is placed as a typed view over the SharedArrayBuffer, because the Atomics operations work on integer arrays.

For security reasons, particularly because of side-channel attacks like Spectre, the browser requires the page to be cross-origin isolated in order to use SharedArrayBuffer: the Cross-Origin-Opener-Policy and Cross-Origin-Embedder-Policy HTTP headers must be set. If those headers are missing, the SharedArrayBuffer constructor is simply unavailable, which in practice is the most common stumbling block on first use.

3. Atomics.wait(): blocking wait, allowed only in workers

Atomics.wait(typedArray, index, value, timeout) checks whether the value at the given position still matches the expected value, and otherwise blocks the calling thread synchronously until the value changes or the timeout expires. This block is a genuine operating-system-level thread pause, during which no other code on that same thread runs at all, not even events or timers.

That is precisely why calling Atomics.wait() on the main thread throws a TypeError, the browser refuses to execute it in the first place. If the main thread were to block, the entire page would freeze, no scrolling, no clicking, no animation would be possible until the wait finishes. In dedicated workers without UI responsibility, this blocking behavior is unproblematic, and in some cases even desired.


// Allowed only inside a worker, blocks the worker thread
const status = Atomics.wait(sharedInt32, 0, 0, 5000);
// status is 'ok', 'not-equal', or 'timed-out'
if (status === 'ok') {
  processReleasedRegion();
}

4. Atomics.waitAsync(): the same wait logic, without blocking the thread

Atomics.waitAsync(typedArray, index, value, timeout) has the same signature as Atomics.wait(), but behaves fundamentally differently: instead of pausing the thread, the method returns immediately with an object holding the properties async and value. If async is false, the value already differed or the result was known immediately, and value directly holds the result string, just as with Atomics.wait().

If async is true, value instead holds a promise that only resolves once the value actually changes, the timeout elapses, or Atomics.notify() is called. While this promise is pending, the thread keeps running completely normally, processes events, executes other tasks, and never freezes at any point, which is the central difference from Atomics.wait().


const result = Atomics.waitAsync(sharedInt32, 0, 0, 5000);

if (result.async) {
  result.value.then((status) => {
    console.log('Woken up with status:', status); // 'ok' or 'timed-out'
  });
} else {
  console.log('Immediate result:', result.value); // e.g. 'not-equal'
}

5. Atomics.notify(): waking waiting threads on purpose

Atomics.notify(typedArray, index, count) wakes up to count threads that are waiting on Atomics.wait() or Atomics.waitAsync() at the given memory position. A call with count equal to 1 wakes exactly one waiting thread, useful for mutex scenarios with exclusive access, while Infinity wakes all waiting threads at once, for example as a broadcast signal to every worker in a pool.

It's important to actually change the shared value before calling notify, usually via Atomics.store(), because notify itself does not set a value, it merely informs waiting threads that something might have happened in memory. The woken threads then re-check the value themselves afterward, which includes the occasional unnecessary wake-up with no actual state change.


// Set the value and wake exactly one waiting thread
Atomics.store(sharedInt32, 0, 1);
Atomics.notify(sharedInt32, 0, 1);

6. Practical example: a simple async mutex across multiple workers

A minimal mutex can be built with a single Int32Array slot: the value 0 means free, the value 1 means locked. A worker that wants to acquire the lock tries to atomically switch from 0 to 1 via Atomics.compareExchange(). If that succeeds, it owns the lock; if it fails, it waits for the next release via Atomics.waitAsync() instead of wasting CPU time in a busy-waiting loop.

On release, the worker resets the value to 0 via Atomics.store() and then calls Atomics.notify() to wake exactly one waiting competitor. This pattern is significantly more efficient than repeated polling via setInterval, because no worker burns CPU cycles while it genuinely has nothing to do.


async function acquireLock(sharedInt32) {
  while (Atomics.compareExchange(sharedInt32, 0, 0, 1) !== 0) {
    const result = Atomics.waitAsync(sharedInt32, 0, 1);
    if (result.async) await result.value;
  }
}

function releaseLock(sharedInt32) {
  Atomics.store(sharedInt32, 0, 0);
  Atomics.notify(sharedInt32, 0, 1);
}

7. Main thread and workers: why waitAsync works everywhere

While Atomics.wait() can only be used inside dedicated workers, Atomics.waitAsync() explicitly works on the main thread too, precisely because it never blocks. That enables scenarios where the main thread itself waits for a state that a worker pool reports over SharedArrayBuffer, such as the completion of a parallel computation, without ever jeopardizing the page's responsiveness.

That makes waitAsync especially valuable for architectures where a central coordinator on the main thread monitors the progress of multiple workers: instead of sending a separate postMessage message for every progress update, the workers can simply increment a shared counter via Atomics.add(), and the main thread wakes up via waitAsync at defined thresholds, with no constant polling at all.

8. Performance: why spin locks and polling are more expensive

A naive alternative to waitAsync would be a spin lock, where a worker repeatedly checks the value in a tight loop until it changes. That consumes CPU time continuously, even while nothing meaningful is happening, and actively competes with other threads for compute time, which noticeably degrades overall page performance on devices with few CPU cores.

Atomics.waitAsync(), by contrast, registers the waiting thread with the operating system's scheduler and returns control entirely until an actual state change occurs. The browser doesn't need to run promise polling at millisecond intervals for this, it gets notified directly by the underlying thread synchronization primitive, which reduces resource consumption to nearly zero as long as no event happens.

9. Limitations, browser support, and a comparison of mechanisms

Atomics.waitAsync(), like SharedArrayBuffer itself, requires a cross-origin isolated environment and is available in modern Chromium and Firefox versions, but not in older browsers. For projects that rely on broad compatibility, a MessageChannel-based signaling fallback remains necessary, even though it causes noticeably more overhead per message for very fine-grained, frequent synchronization.

In practice, Atomics.waitAsync() pays off primarily where multiple workers need to synchronize frequently and with low latency over shared memory, such as parallel image or audio processing. For rare, coarse-grained coordination between a worker and the main thread, a simple postMessage message is often still the simpler and sufficiently performant choice.

Mechanism Blocks the thread Usable on the main thread Typical use
Atomics.wait() Yes, synchronously until woken No, throws TypeError Dedicated worker with strict ordering
Atomics.waitAsync() No Yes Mutex/semaphore across multiple workers
postMessage / MessageChannel No Yes Rare, coarse-grained coordination
Manual busy-spin loop No, but CPU-intensive Technically yes, but discouraged Not recommended, testing purposes only

Mironsoft

Modern browser APIs, performance, and maintainable JavaScript

JavaScript that holds up in the real browser, not just in the tutorial?

We review existing frontend code for outdated patterns, unnecessary dependencies, and performance traps, then replace them with modern, native browser APIs that mean less bundle weight and less maintenance burden.

Code Review

Systematically finding outdated patterns, unnecessary dependencies, and memory leaks.

Performance Optimization

Improving bundle size, load time, and runtime performance with modern APIs.

Modernization

Deliberately introducing native browser APIs instead of heavy libraries.

10. Summary

Atomics.waitAsync(): The Key Facts at a Glance

Core idea

Atomics.waitAsync() waits for a memory change without blocking the executing thread.

Difference from wait()

Atomics.wait() blocks synchronously and is forbidden on the main thread, waitAsync works everywhere.

How they interact

Atomics.notify() deliberately wakes waiting threads, usually after an Atomics.store().

Prerequisite

SharedArrayBuffer, and therefore waitAsync too, requires a cross-origin isolated page.

11. FAQ: Atomics.waitAsync(): The Key Facts at a Glance

1Why is Atomics.wait() forbidden on the main thread?
Because it blocks the calling thread synchronously. On the main thread that would freeze the entire page, no scrolling, clicking, or animation would be possible until the wait finishes, which is why the browser throws a TypeError there.
2What does the async property in waitAsync's return value mean?
async indicates whether the result was already known synchronously, because the value already differed, or whether it gets delivered asynchronously via a promise. When async is true, value holds the promise; when false, value directly holds the result string.
3Do I strictly need a SharedArrayBuffer for Atomics.waitAsync()?
Yes, Atomics operations work on typed arrays over a SharedArrayBuffer, because only that kind of memory is actually shared between multiple threads. A regular ArrayBuffer is not suitable for this.
4Why do I need cross-origin isolation for this use case?
For security reasons, SharedArrayBuffer is only made available on pages with the Cross-Origin-Opener-Policy and Cross-Origin-Embedder-Policy headers set, to make side-channel attacks like Spectre harder. Without these headers, the constructor is simply not present.
5What exactly does Atomics.notify() do?
Atomics.notify() wakes a given number of threads that are waiting at a specific memory position via wait or waitAsync. It does not set a value itself, so the shared value must be changed separately beforehand via Atomics.store().
6How do I build a mutex with these primitives?
Atomics.compareExchange() is used to atomically try to switch a lock value from free to locked. If that fails, the thread waits via waitAsync for the next release instead of polling in a loop; on release, the owner resets the value and calls notify.
7Is Atomics.waitAsync() faster than a polling loop?
Significantly, because a polling loop consumes CPU time continuously, while waitAsync registers the waiting thread with the scheduler and only becomes active again on an actual state change, reducing resource consumption during wait periods to nearly zero.
8Can I use waitAsync without serving SharedArrayBuffer on the server side?
No, without the matching cross-origin isolation headers the browser refuses the SharedArrayBuffer constructor entirely, regardless of whether Atomics.waitAsync() itself is supported by the browser.
9Do all modern browsers support Atomics.waitAsync()?
Current Chromium and Firefox versions support the method, but it's missing in older browser versions. For broad compatibility, a MessageChannel-based signaling fallback remains sensible.
10When is waitAsync not worth it, and a plain postMessage enough?
For rare, coarse-grained coordination between a worker and the main thread, such as reporting a single result once, a regular postMessage message is often easier to understand and sufficiently performant, without the added complexity of SharedArrayBuffer and cross-origin isolation.