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.
Table of Contents
- 1. The coordination problem between multiple web workers
- 2. Foundation: SharedArrayBuffer and cross-origin isolation
- 3. Atomics.wait(): blocking wait, allowed only in workers
- 4. Atomics.waitAsync(): the same wait logic, without blocking the thread
- 5. Atomics.notify(): waking waiting threads on purpose
- 6. Practical example: a simple async mutex across multiple workers
- 7. Main thread and workers: why waitAsync works everywhere
- 8. Performance: why spin locks and polling are more expensive
- 9. Limitations, browser support, and a comparison of mechanisms
- 10. Summary
- 11. FAQ
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.