Coordinating Workers with Atomics.wait and notify
Atomics.wait and Atomics.notify are a futex (fast userspace mutex) for the browser. They let a worker thread park itself cheaply until another thread signals it — no polling loop, no wasted CPU cycles.
This page is a focused companion to the SharedArrayBuffer & Atomics reference, which covers the full shared-memory model including COOP/COEP setup. For the broader thread-boundary context, see Web Workers Architecture & Communication.
Atomics.wait and Atomics.notify operate on Int32Array or BigInt64Array views over a SharedArrayBuffer. That buffer is only available when the page is cross-origin isolated: Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp must both be present on every navigation response.
Minimal Reproducible Example
The following example sets up a producer worker that writes a value and notifies a consumer worker, which is parked via Atomics.wait.
// shared-types.ts
export const SAB_SLOTS = 4;
export const SIGNAL_SLOT = 0; // 0 = waiting, 1 = data ready
export const DATA_SLOT = 1;
// main.ts
const sab = new SharedArrayBuffer(SAB_SLOTS * Int32Array.BYTES_PER_ELEMENT);
const consumer = new Worker(new URL('./consumer.ts', import.meta.url), { type: 'module' });
const producer = new Worker(new URL('./producer.ts', import.meta.url), { type: 'module' });
consumer.postMessage({ type: 'INIT', sab });
// Give consumer a moment to reach its wait() call, then start producer
setTimeout(() => producer.postMessage({ type: 'INIT', sab }), 50);
// consumer.ts
import { SIGNAL_SLOT, DATA_SLOT } from './shared-types.js';
self.onmessage = ({ data }) => {
if (data.type !== 'INIT') return;
const view = new Int32Array(data.sab);
// Park this worker thread until SIGNAL_SLOT changes away from 0.
// Returns "ok" | "not-equal" | "timed-out"
const result = Atomics.wait(view, SIGNAL_SLOT, 0, 2000 /* ms */);
if (result === 'ok') {
const value = Atomics.load(view, DATA_SLOT);
self.postMessage({ type: 'RECEIVED', value });
} else {
self.postMessage({ type: 'TIMEOUT' });
}
};
// producer.ts
import { SIGNAL_SLOT, DATA_SLOT } from './shared-types.js';
self.onmessage = ({ data }) => {
if (data.type !== 'INIT') return;
const view = new Int32Array(data.sab);
// Write the payload BEFORE flipping the signal —
// this ordering ensures the consumer never reads stale data.
Atomics.store(view, DATA_SLOT, 42);
Atomics.store(view, SIGNAL_SLOT, 1); // flip signal
const woken = Atomics.notify(view, SIGNAL_SLOT, 1); // wake up to 1 waiter
console.log('Workers woken:', woken); // 1 if consumer was parked, 0 if not yet waiting
};
Line-by-Line Walkthrough
Atomics.wait(view, index, expectedValue, timeout?)
- Reads
view[index]atomically. - If the current value does not equal
expectedValue, returns"not-equal"immediately without parking. - If the values match, the thread parks (suspends execution) until either:
- Another thread calls
Atomics.notify(view, index, ...), returning"ok". - The optional
timeoutmilliseconds elapse, returning"timed-out".
- Another thread calls
The expectedValue check-before-park is not a race: the read and the park happen atomically from the OS scheduler’s perspective (the thread is added to the wait queue while holding a kernel-level lock on the word).
Atomics.notify(view, index, count)
- Wakes up to
countthreads waiting onview[index]. PassInfinityto wake all waiters. - Returns the number of threads actually woken.
- Is a no-op if no threads are currently parked on that slot.
- Can be called from the main thread, from a worker, or from any shared-memory context.
Atomics.waitAsync(view, index, expectedValue, timeout?)
Returns an object { async: boolean, value: string | Promise<string> }. When async is true, await value to get the "ok" / "not-equal" / "timed-out" result. This is the only safe form for main-thread coordination code.
// main.ts — waiting for a worker result without blocking the event loop
const view = new Int32Array(sab);
const { async, value } = Atomics.waitAsync(view, SIGNAL_SLOT, 0, 5000);
if (async) {
const result = await (value as Promise<string>);
console.log('waitAsync resolved:', result);
} else {
// value was already !== expectedValue before we could park
console.log('Already signalled:', value);
}
Building a Tiny Mutex
A mutex (mutual exclusion lock) protects a critical section from concurrent access. Here is a complete, minimal futex-backed mutex using Int32Array over a SharedArrayBuffer.
// mutex.ts — import in any worker that shares the SAB
export const MUTEX_UNLOCKED = 0;
export const MUTEX_LOCKED = 1;
/**
* Acquire the mutex at `slot`. Blocks until the lock is available.
* Only call from a Worker — never from the main thread.
*/
export function mutexLock(view: Int32Array, slot: number): void {
while (true) {
// Try to atomically swap 0 (unlocked) → 1 (locked)
const old = Atomics.compareExchange(view, slot, MUTEX_UNLOCKED, MUTEX_LOCKED);
if (old === MUTEX_UNLOCKED) return; // We acquired the lock
// Lock is held by another thread — park until it is released
// Ignore "not-equal" (lock was released between CAS and wait) and retry
Atomics.wait(view, slot, MUTEX_LOCKED);
}
}
/**
* Release the mutex at `slot` and wake one waiter.
*/
export function mutexUnlock(view: Int32Array, slot: number): void {
Atomics.store(view, slot, MUTEX_UNLOCKED);
Atomics.notify(view, slot, 1);
}
// usage in a worker
import { mutexLock, mutexUnlock } 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);
mutexLock(view, LOCK_SLOT);
try {
// Critical section — only one worker at a time
const current = Atomics.load(view, COUNTER_SLOT);
Atomics.store(view, COUNTER_SLOT, current + 1);
} finally {
mutexUnlock(view, LOCK_SLOT);
}
};
The finally block ensures the lock is always released even if the critical section throws. Without it, any exception leaves the mutex permanently locked — a classic deadlock.
Building a Wait-for-Signal (One-Shot Event)
A simpler pattern than a mutex is a one-shot event: the consumer waits for a single signal from the producer.
// signal.ts
export const SIG_PENDING = 0;
export const SIG_FIRED = 1;
export const SIG_SLOT = 2; // use a different slot from any mutex
/** Producer: fire the signal. */
export function signalFire(view: Int32Array): void {
Atomics.store(view, SIG_SLOT, SIG_FIRED);
Atomics.notify(view, SIG_SLOT, Infinity); // wake all waiters
}
/**
* Consumer: wait for the signal.
* Returns true if signalled in time, false on timeout.
* Workers only — use signalWaitAsync for the main thread.
*/
export function signalWait(view: Int32Array, timeoutMs = 5000): boolean {
// Check first — signal may already be fired
if (Atomics.load(view, SIG_SLOT) === SIG_FIRED) return true;
const result = Atomics.wait(view, SIG_SLOT, SIG_PENDING, timeoutMs);
return result !== 'timed-out';
}
/** Main-thread variant — non-blocking. */
export async function signalWaitAsync(view: Int32Array, timeoutMs = 5000): Promise<boolean> {
if (Atomics.load(view, SIG_SLOT) === SIG_FIRED) return true;
const { value } = Atomics.waitAsync(view, SIG_SLOT, SIG_PENDING, timeoutMs);
const result = await (value as Promise<string>);
return result !== 'timed-out';
}
Gotchas & Edge Cases
1. Atomics.wait is a worker-only API
Calling it on the main thread throws TypeError: Atomics.wait cannot be called in this context. Gate all wait calls behind a check if your code may run in either context:
function safeWait(view: Int32Array, slot: number, expected: number): void {
if (typeof WorkerGlobalScope !== 'undefined' && self instanceof WorkerGlobalScope) {
Atomics.wait(view, slot, expected);
} else {
throw new Error('Use Atomics.waitAsync on the main thread');
}
}
2. Lost wakes (notify before wait)
If the notifying thread runs Atomics.notify before the waiting thread reaches Atomics.wait, the wake is silently dropped. The safe pattern is: check the slot value first, then conditionally call wait:
// Safe wait idiom — avoids missing a notify that fired early
if (Atomics.load(view, SIGNAL_SLOT) === SIG_PENDING) {
Atomics.wait(view, SIGNAL_SLOT, SIG_PENDING, 2000);
}
// At this point either the condition changed before we waited, or notify arrived
3. Spurious wakes
The ECMAScript spec does not guarantee that Atomics.wait wakes only when notified. Implementations may return "ok" spuriously. Always re-check the guarded condition after waking:
while (Atomics.load(view, SIGNAL_SLOT) !== SIG_FIRED) {
Atomics.wait(view, SIGNAL_SLOT, SIG_PENDING, 100);
}
4. Only Int32Array and BigInt64Array are valid for wait/notify
Atomics.wait accepts only Int32Array or BigInt64Array views over a SharedArrayBuffer. Passing a Float32Array or Uint8Array throws a TypeError. Use a dedicated Int32Array for flag and lock slots, even if your data lives in a different typed array view over the same buffer.
Performance Rule of Thumb
A Atomics.wait / Atomics.notify round-trip (park + wake) completes in approximately 0.01–0.05 ms on a 2023 laptop — roughly 5–20 µs per wake cycle. That is around 10–50× faster than a postMessage round-trip (0.1–0.5 ms) for the same signal. Use it when coordination latency is on the critical path, such as in real-time audio worklets or high-frequency telemetry pipelines.