Coordinating Workers with Atomics.wait and notify
Atomics.wait and Atomics.notify are a futex — a fast userspace mutex — for the browser: they let a worker park itself on a single 32-bit word in shared memory and be woken by another thread in microseconds, with no polling loop burning a core.
This page is a focused companion to the SharedArrayBuffer & Atomics reference, itself part of the Web Workers Architecture & Communication overview. The parent page covers the shared-memory model, the isolation requirements and the full Atomics surface; this one covers only the two blocking operations, the exact ways they are misused, and the three ready-made primitives — a signal, a mutex and a non-blocking main-thread wait — you can lift straight into a worker.
Atomics.wait and Atomics.notify operate on Int32Array or BigInt64Array views over a SharedArrayBuffer, and that buffer only exists when the document is cross-origin isolated. The initial navigation response must carry both Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp, and every cross-origin subresource must opt in with CORP or CORS. Check console.log(crossOriginIsolated) first — if it is not true, work through Debugging SharedArrayBuffer Cross-Origin Errors before touching any of the code below.
Minimal Reproducible Example
Four files: a shared constants module, a consumer worker that parks, a producer worker that signals, and the main thread that wires them together. The consumer never polls and never spins.
// shared-slots.ts — imported by both workers
export const SLOT_COUNT = 4;
export const SIGNAL_SLOT = 0; // 0 = pending, 1 = data ready
export const DATA_SLOT = 1;
export const PENDING = 0;
export const READY = 1;
export interface InitMessage {
type: 'INIT';
sab: SharedArrayBuffer;
}
// consumer-worker.ts — blocking is legal here; this is not the main agent
import { SIGNAL_SLOT, DATA_SLOT, PENDING, READY, InitMessage } from './shared-slots.js';
self.onmessage = ({ data }: MessageEvent<InitMessage>) => {
if (data.type !== 'INIT') return;
const view = new Int32Array(data.sab);
// Re-check in a loop: a wake-up is permission to look, not proof of readiness.
while (Atomics.load(view, SIGNAL_SLOT) !== READY) {
// Park only if the slot still holds PENDING. If the producer already
// flipped it, wait() returns 'not-equal' without ever suspending.
const result = Atomics.wait(view, SIGNAL_SLOT, PENDING, 2000 /* ms */);
if (result === 'timed-out') {
self.postMessage({ type: 'TIMEOUT' });
return;
}
}
const value = Atomics.load(view, DATA_SLOT);
self.postMessage({ type: 'RECEIVED', value });
};
// producer-worker.ts
import { SIGNAL_SLOT, DATA_SLOT, READY, InitMessage } from './shared-slots.js';
self.onmessage = ({ data }: MessageEvent<InitMessage>) => {
if (data.type !== 'INIT') return;
const view = new Int32Array(data.sab);
view[DATA_SLOT] = 42; // 1. payload first
Atomics.store(view, SIGNAL_SLOT, READY); // 2. publish (release edge)
const woken = Atomics.notify(view, SIGNAL_SLOT, 1); // 3. wake one waiter
// 0 is not a failure: it means nobody was parked yet — and because the
// consumer re-reads the slot before parking, it will not park at all.
self.postMessage({ type: 'SIGNALLED', woken });
};
// main.ts
import { SLOT_COUNT, InitMessage } from './shared-slots.js';
const sab = new SharedArrayBuffer(SLOT_COUNT * Int32Array.BYTES_PER_ELEMENT);
const init: InitMessage = { type: 'INIT', sab };
const consumer = new Worker(new URL('./consumer-worker.ts', import.meta.url), { type: 'module' });
const producer = new Worker(new URL('./producer-worker.ts', import.meta.url), { type: 'module' });
consumer.onmessage = ({ data }) => console.log('consumer:', data);
consumer.postMessage(init); // no ordering requirement — see the walkthrough
producer.postMessage(init);
Note what the main thread does not do: there is no setTimeout delay to “give the consumer time to reach its wait call”. Needing one would be a symptom of a lost-wake bug, and the load-then-wait loop in the consumer removes it. The buffer is cloned by reference on postMessage, so both workers view the same bytes — unlike a transferable object, nothing is detached from the sender.
Step-by-Step Walkthrough
Atomics.wait(typedArray, index, expectedValue, timeout?) — an atomic compare-then-park. The call reads typedArray[index] and compares it to expectedValue as a single indivisible step with joining the wait queue. That indivisibility is the entire point of the API and the reason it cannot be built out of Atomics.load plus a hypothetical park(): there is no window between the comparison and the sleep for a notify to slip through. It returns one of exactly three strings:
| Return value | Meaning | What the caller should do |
|---|---|---|
"not-equal" |
The slot did not hold expectedValue; the thread never parked |
Re-read the condition and continue — this is the fast path, not an error |
"ok" |
The thread parked and was woken by Atomics.notify (or spuriously) |
Re-test the condition, then proceed |
"timed-out" |
The thread parked and timeout milliseconds elapsed first |
Treat as a stall: retry, back off, or report |
The timeout argument is in milliseconds, defaults to Infinity, and is clamped to a non-negative number. Passing 0 is a legal way to ask “is this slot still expectedValue?” without ever suspending — it returns "not-equal" or "timed-out" and never "ok".
Atomics.notify(typedArray, index, count?) — wake up to count parked agents. It returns the number of agents actually woken, defaults count to Infinity, and is a no-op returning 0 when nobody is parked on that index. Three properties matter in practice:
- It has no memory. A notify that arrives before any waiter is not stored anywhere; the state that survives is only whatever you wrote into the slot.
- It matches on the memory location, not on the view. Two
Int32Arrayviews over the sameSharedArrayBufferat the same byte offset are the same wait target, so a helper module may construct its own view freely. - It may be called from any agent, including the main thread and non-shared contexts. Only
waitis restricted;notifynever blocks.
Payload before signal — the ordering is the correctness argument. In producer-worker.ts, view[DATA_SLOT] = 42 is a plain write and Atomics.store(view, SIGNAL_SLOT, READY) is a sequentially-consistent one. The consumer’s Atomics.load of the signal slot synchronizes-with that store, so everything sequenced before the store in the producer happens-before everything the consumer does after the load — including that unordered payload write. Swap the two lines and the consumer can legally observe READY alongside a stale data slot. The same release/acquire pairing is what makes the index publishes safe in Building a Lock-Free Ring Buffer with Atomics.
The while loop around wait is not defensive padding. It absorbs three separate cases with one construct: the signal already fired before the consumer arrived (Atomics.load sees READY, no wait at all); the signal fired during the call ("not-equal", loop re-tests and exits); and a spurious or unrelated wake ("ok" with the condition still false, loop re-parks). Code that calls wait once and trusts the result is correct only for the exact interleaving the author happened to test.
Waiting Without Blocking: Atomics.waitAsync
Atomics.wait throws on the main thread, but the main thread often is the party that needs to know a worker finished. Atomics.waitAsync joins the same wait queue and is woken by the same Atomics.notify call — it simply resolves a promise instead of suspending the agent.
It returns an object with a discriminating async flag:
{ async: false, value: "not-equal" }— the slot had already changed; nothing was queued.{ async: false, value: "timed-out" }— you passed a timeout of0.{ async: true, value: Promise<"ok" | "timed-out"> }— queued; await the promise.
// main.ts — resolve when a worker flips the slot, without freezing the UI
import { SIGNAL_SLOT, PENDING, READY } from './shared-slots.js';
export function waitForSignal(view: Int32Array, timeoutMs = 5000): Promise<boolean> {
// Fast path: already signalled, no queue entry, no microtask needed.
if (Atomics.load(view, SIGNAL_SLOT) === READY) return Promise.resolve(true);
const r = Atomics.waitAsync(view, SIGNAL_SLOT, PENDING, timeoutMs);
if (!r.async) return Promise.resolve(r.value === 'not-equal'); // changed under us
return r.value.then((result) => {
if (result === 'timed-out') return false;
// 'ok' means "look again", exactly as in the blocking case.
return Atomics.load(view, SIGNAL_SLOT) === READY;
});
}
The resolution is delivered as a job on the microtask queue of the calling agent, which has a consequence worth internalising: a waitAsync promise cannot resolve while the main thread is busy in a long synchronous task. It is not a lower-latency postMessage — it is the same latency floor as any other task the event loop is holding up. When the main thread is genuinely the bottleneck, the answer is to move the work behind the signal rather than the signal itself.
A Futex-Backed Mutex in Twenty Lines
A mutex protects a critical section so that only one agent executes it at a time. The classic futex construction takes the uncontended path entirely in userspace — a single compare-and-exchange, no syscall, no park — and only touches the wait queue when there is actual contention.
// mutex.ts — import in any worker that shares the buffer
export const UNLOCKED = 0;
export const LOCKED = 1;
/** Worker-only: blocks until the lock at `slot` is acquired. */
export function lock(view: Int32Array, slot: number): void {
for (;;) {
// Uncontended fast path: one atomic CAS, no kernel involvement.
if (Atomics.compareExchange(view, slot, UNLOCKED, LOCKED) === UNLOCKED) return;
// Contended: park until the holder releases. A 'not-equal' return means the
// lock was released between the CAS and the wait — fall through and retry.
Atomics.wait(view, slot, LOCKED, 1000);
}
}
/** Release the lock and wake exactly one waiter. */
export function unlock(view: Int32Array, slot: number): void {
if (Atomics.compareExchange(view, slot, LOCKED, UNLOCKED) !== LOCKED) {
throw new Error('unlock() called on a mutex this agent does not hold');
}
Atomics.notify(view, slot, 1);
}
// counter-worker.ts — usage
import { lock, unlock } from './mutex.js';
const LOCK_SLOT = 0;
const COUNTER_SLOT = 1;
self.onmessage = ({ data }) => {
if (data.type !== 'INIT') return;
const view = new Int32Array(data.sab);
lock(view, LOCK_SLOT);
try {
// Critical section: a multi-step read-modify-write that Atomics.add
// could not express as a single operation.
const current = Atomics.load(view, COUNTER_SLOT);
Atomics.store(view, COUNTER_SLOT, current + 1);
} finally {
unlock(view, LOCK_SLOT); // runs even if the body throws
}
};
Two details carry most of the weight. The finally block is mandatory: an exception inside the critical section that escapes without unlocking leaves the slot at LOCKED with no owner, and every other worker parks forever — a deadlock that presents as a silently frozen pipeline, not as an error. And unlock uses compareExchange rather than a bare store so that releasing a lock you never took is a loud Error instead of silent corruption.
Note also Atomics.notify(view, slot, 1): waking one waiter rather than all of them avoids the thundering herd, where n parked workers all wake, all CAS, one wins and n − 1 immediately re-park having achieved nothing but cache-line traffic. Wake all only when the condition can genuinely be satisfied for every waiter at once — a one-shot broadcast event, as below.
lock()'s for(;;): fail the exchange, park, wake, try again. Everything expensive lives there — the uncontended case never leaves the top row of the diagram at all.If all you need is a counter increment, skip the mutex entirely — Atomics.add(view, COUNTER_SLOT, 1) is a single atomic read-modify-write and cannot be beaten by any lock. A mutex earns its cost only when the critical section spans several slots that must move together.
A One-Shot Broadcast Signal
The complement to a mutex is a latch that fires once and stays fired: initialisation complete, dataset loaded, shutdown requested. Because it never resets, waiters that arrive late must still see it — which is precisely why the load-before-wait check is not optional here.
// latch.ts
export const LATCH_SLOT = 2; // must not collide with any mutex slot
const PENDING = 0;
const FIRED = 1;
/** Any agent, including the main thread. Idempotent. */
export function fire(view: Int32Array): void {
Atomics.store(view, LATCH_SLOT, FIRED);
Atomics.notify(view, LATCH_SLOT); // count defaults to Infinity: wake everyone
}
/** Worker-only. Returns false if it timed out before the latch fired. */
export function awaitLatch(view: Int32Array, timeoutMs = 5000): boolean {
const deadline = performance.now() + timeoutMs;
while (Atomics.load(view, LATCH_SLOT) !== FIRED) {
const remaining = deadline - performance.now();
if (remaining <= 0) return false;
// Re-arm the timeout on every iteration so spurious wakes cannot
// silently extend the total wait past the caller's budget.
Atomics.wait(view, LATCH_SLOT, PENDING, remaining);
}
return true;
}
Recomputing remaining from a deadline rather than passing the original timeoutMs on each pass is the difference between a bounded wait and an unbounded one. A version that re-passes the full timeout after every wake will, under a stream of spurious or unrelated wakes, wait considerably longer than the caller asked for — and that is exactly the kind of bug that only appears under production load.
Atomics.notify call with its count left at Infinity — the one case where waking every waiter is correct, because the condition is now true for all of them simultaneously and none will have to re-park.Gotchas & Edge Cases
Atomics.wait is unavailable in more contexts than just the window
The restriction is not “main thread versus worker” but the agent’s [[CanBlock]] flag. It is true in a dedicated worker and false in the window agent, in a shared worker, and in a service worker — so a shared worker calling wait throws TypeError, which surprises people who reason about it as “not the main thread”. Gate the call on the capability rather than on the context you assume you are in:
const canBlock =
typeof WorkerGlobalScope !== 'undefined' &&
self instanceof WorkerGlobalScope &&
typeof DedicatedWorkerGlobalScope !== 'undefined' &&
self instanceof DedicatedWorkerGlobalScope;
Atomics.wait. That is why the capability check above tests for DedicatedWorkerGlobalScope specifically rather than for "some kind of worker".Only Int32Array and BigInt64Array may be waited on
Atomics.wait and Atomics.waitAsync reject every other view type with a TypeError, including Uint32Array, which does support the non-blocking Atomics operations. Keep flag and lock slots in a dedicated Int32Array even when your payload lives in a Float64Array or Uint8Array over the same buffer — overlapping views are fine and cost nothing, and the alignment requirement means the wait index is a element index into that view, not a byte offset.
A wake is a hint, not a fact
Nothing in the specification promises that "ok" implies the condition you care about is true. A spurious wake is permitted, another waiter may have consumed the state between your wake and your first read, and a notify on the same slot from unrelated code will wake you too. Every wait therefore belongs inside a loop that re-tests the real condition. This is the single most common source of intermittent, machine-dependent bugs in shared-memory worker code, because a wrong version passes on a fast laptop and fails on a loaded CI runner.
An unbounded wait turns a bug into a hang
Atomics.wait with no timeout parks indefinitely, and a worker parked indefinitely does not respond to postMessage, cannot be interrupted, and shows no stack activity in a profiler — terminate() from the main thread is the only exit. Always pass a finite timeout in production, treat "timed-out" as a real branch with logging attached, and keep the deadline shorter than any user-visible operation it sits under. The same reasoning applies to the drain loops in a worker pool: see Worker Pool Management for how a parked worker should be accounted for while it is unavailable.
Performance Note
An uncontended Atomics.compareExchange costs on the order of 20 ns — it never leaves userspace. A park-and-wake round trip through Atomics.wait/Atomics.notify costs roughly 5–50 µs, because it involves the OS scheduler descheduling and rescheduling a thread. A postMessage round trip of a small object sits in the same tens-of-microseconds band once structured cloning, task dispatch and the receiving event loop are counted, but it also has to wait for whatever that event loop is already doing.
The practical consequence is that the win from a futex is not raw wake latency — it is avoiding the wake altogether. A well-shaped protocol takes the fast path ("not-equal", or a load that shows the condition already satisfied) the overwhelming majority of the time and only parks when the thread would otherwise have nothing to do. That is why the fast-path check in waitForSignal and the CAS-first structure of lock matter more than any micro-optimisation of the waiting code: they convert most coordination events into a single atomic instruction.
The corollary is a rule for choosing between mechanisms at all. If your workers exchange a message every few hundred milliseconds, the coordination cost is invisible either way and postMessage is simpler, safer and needs no isolation headers. Shared-memory parking earns its complexity when the signal rate is high enough that the event loop itself becomes the bottleneck — an audio worklet feeding a decoder, a physics step synchronising with a render thread, or a producer emitting tens of thousands of items per second. postMessage vs SharedArrayBuffer: When to Choose Each works that decision through with numbers; measure your own workload before adopting the harder tool.