HomeJournalThis post

Atomics.waitAsync for Worker Coordination

Coordinate browser workers with Atomics.waitAsync, monotonic versions, state rechecks, timeout and cancellation, plus a labeled message fallback.

JP
JP Casabianca
AI Engineer and Product Designer · full-stack delivery · Bogotá

Atomics.waitAsync waits on shared-memory equality without synchronously blocking the calling agent. This tutorial builds the versioned state machine that keeps wakeups, timeouts, cancellation, and fallbacks honest.

Atomics.waitAsync waits on a value, not an event

Atomics.waitAsync compares one shared integer with an expected value. If they differ, it returns immediately with a synchronous “not-equal” result. If they match, the agent may wait and receive a promise-backed “ok” or “timed-out” result. This is a conditional wait, not an event subscription, and every correct design begins with that distinction.

The return is a record with an async boolean and a value that is either a string or a promise. Code that always awaits the value can normalize the two shapes, but the receipt should preserve which path happened. The normative ECMAScript Atomics.waitAsync algorithm defines those results and validates the typed array, index, expected value, and timeout.

Use Atomics.waitAsync when a JavaScript agent must pause progress around shared-memory state without synchronously blocking that agent. That makes it a specific non-blocking Atomics wait primitive, not a general event bus. Do not begin with a notification API and invent state afterward. Put durable meaning in shared slots, treat notifications as hints that state may have changed, and recheck the condition after every return.

Model the wait record explicitly

A tiny normalizer makes the contract reviewable: call Atomics.waitAsync, record async, then resolve value only if it is promise-like. Preserve the expected value, observed value before waiting, timeout, and result string. If the result is “ok,” load the state again; “ok” says a wake occurred, not that your higher-level task completed.

Atomics.waitAsync can return “not-equal” before a promise exists. That path is important because it closes the notify-before-wait race: if a producer already changed the version, the consumer detects the mismatch and proceeds. A design that expects every notify to pair with one waiting promise will misclassify this correct immediate return as an error.

The first figure shows both branches without relying on color. A solid arrow marks the synchronous comparison path; a dotted arrow marks the promise-backed wait path. The transcript underneath lists all fields. This representation is deliberately a value-flow diagram rather than a browser-support graphic, because semantics must remain correct even when a fallback implements a different transport.

Anatomy of the waitAsync return recordThe initial value comparison splits into an immediate not-equal result or a promise-backed ok or timed-out result, followed by a mandatory state recheck.load valuecompare expectedasync: falsevalue: not-equalasync: truevalue: Promiserecheckok / timed-out
Anatomy of the waitAsync return record
The initial value comparison splits into an immediate not-equal result or a promise-backed ok or timed-out result, followed by a mandatory state recheck.
Return paths
ComparisonRecordNext action
Observed differs from expectedasync false; not-equalEvaluate shared state now
Observed equals expectedasync true; promiseAwait ok or timed-out, then recheck
Figure 1: The primitive waits on equality; no return value means higher-level work is complete.

Design a versioned shared state machine

Reserve explicit slots for version, status, generation, and payload indices. A producer writes payload first, then status, then increments the version with atomic operations and notifies waiters. A consumer snapshots the expected version, examines status, waits only while the version is unchanged, and re-runs the state transition after waking. Monotonic versions make duplicate notifications harmless.

For Atomics.waitAsync, ownership belongs in the state machine: one agent may write payload for a generation, another may advance status, and both must know who can cancel. A generation token distinguishes a restarted worker from late completion by its predecessor. Without it, an old message can mark new work complete after termination and restart.

The lab uses a four-slot Int32Array backed by SharedArrayBuffer when the browser exposes the capability. This SharedArrayBuffer wait notify design gives JavaScript worker coordination a versioned contract. The fixture is intentionally bounded; for a richer memory layout, reuse a proven SharedArrayBuffer ring buffer. Document every slot, allowed transition, and writer. Shared memory without an ownership table is merely globally accessible ambiguity.

Pair the wait loop with Atomics.notify

A producer calls Atomics.notify after publishing state. The return value is the number of waiting agents that were notified, not the number of consumers that completed work. Zero is valid when nobody is currently waiting; the changed version remains the source of truth. A consumer loops because wakeups, timeouts, cancellation, or unrelated notifications can all lead back to the same check.

The TC39 proposal history explains the motivation for a non-blocking variant and the host-controlled resolution path. In product code, hide the mechanics behind an operation such as waitForVersionAfter(n), not a promise called nextEvent. The name reminds callers that state, not notify count, determines progress.

Atomics.waitAsync does not replace backpressure. A producer can outrun consumers even if wakeups are perfect. Separate synchronization from backpressure policy by deciding buffer capacity, admission, dropping, and retry independently. Notify answers “someone may recheck”; it does not answer “the system can accept more work.”

Treat timeout and cancellation as states

A timeout is not an exception unless the application declares it one. Record the expected version, elapsed policy, final observed version, generation, and next transition. The caller may retry, show partial progress, abandon the generation, or escalate to a durable service. Hiding “timed-out” in a generic catch block destroys the information needed to choose.

Cancellation needs ownership. Increment a generation or set a cancellation status atomically, notify waiters, and require every completion to compare its captured generation with the current one. Atomics.waitAsync promises are not themselves a complete cancellation protocol. An AbortSignal can control the surrounding operation, but shared state must still reject late writes.

This mirrors structured concurrency for owned child work: the parent owns lifetime, cancellation propagates, and completion cannot outlive its generation unnoticed. The lab terminates a worker, advances generation, restarts, and verifies that a late predecessor cannot complete the successor’s task. That is a synthetic schedule check, not a fairness guarantee.

Versioned two-worker wakeup timelinePayload publication, version increment, notify, timeout, termination, and generation-safe restart appear as ordered state transitions.UI agentshared slotsworker 1worker 2publish generation 1version 1 → 2; notifytimeout: unchanged means no completionterminate; generation 1 stalerestart generation 2; reject late generation 1
Versioned two-worker wakeup timeline
Payload publication, version increment, notify, timeout, termination, and generation-safe restart appear as ordered state transitions.
  1. UI creates generation 1 and expected version 1.
  2. Worker publishes payload, increments version to 2, then notifies.
  3. A later timeout leaves state unchanged and is logged.
  4. Termination advances the active generation.
  5. Worker 2 begins generation 2; late generation-1 completion is rejected.
Figure 2: Version and generation—not notify count—decide whether work belongs to the active run.

Keep the main thread responsive

Synchronous Atomics.wait may not be allowed on an agent that cannot block, including the browser’s main agent. Atomics.waitAsync was designed so the caller can continue processing while waiting. That does not make surrounding work cheap. Heavy computation before or after the await can still freeze input and rendering.

Keep UI state separate from worker protocol state. Render a human status from version, generation, and transition logs, and keep focus on the control that initiated work. Announce meaningful state changes through a polite live region rather than every notify. The interactive lab uses buttons with stable labels and a fixed-height log to avoid layout jumps.

When coordinating off-thread rendering without freezing input, measure the full interaction: message preparation, shared writes, worker compute, wake resolution, result transfer, and paint. Atomics.waitAsync only addresses one waiting edge. The article makes no INP or scheduling-latency claim; the fixture uses deterministic state assertions rather than timing rankings.

Feature-detect capability and isolation

Check for SharedArrayBuffer, Atomics.waitAsync, and the isolation requirements of the deployment. The HTML shared-memory section describes the security model around shared memory and agents. A browser may expose syntax while the page cannot construct usable shared memory in its current context.

The fallback in this tutorial uses MessageChannel. It preserves versioned transitions and cancellation semantics but does not share memory and cannot reproduce conditional atomic waiting. Label the active adapter in the receipt. “Fallback” should mean a useful alternative with named differences, never fake parity.

Atomics.waitAsync can also coexist with blocking Atomics.wait inside a worker where blocking is acceptable. The progressive matrix compares calling agent, shared-memory requirement, persistence, timeout shape, and failure behavior. Server coordination is included as the durable option for work that must survive page closure. Feature detection should choose among valid architectures, not merely silence an exception.

Run hostile worker schedules

The browser lab exercises six cases: immediate “not-equal,” asynchronous wake after a version increment, timeout with unchanged state, duplicate notify, worker termination, and generation-safe restart. Each transition logs generation, previous version, next version, status, adapter, and the observed wait result. The same button reruns the deterministic sequence from a clean fixture.

The first adversarial case notifies before the consumer waits. Correct code sees the changed version and uses the synchronous path. Another case sends duplicate notifications without changing state; the consumer rechecks and refuses to invent progress. The restart case captures a stale generation and proves that its completion is ignored.

When Atomics.waitAsync or shared memory is unavailable, the lab exposes that status and runs the MessageChannel scenario instead. It does not label the fallback as an Atomics conformance result. Open the artifact from a served origin, inspect the receipt, then compare the two-worker timeline with the logged versions. Every completion should be explainable from state, never merely from the presence of a notification.

Runnable artifact — A bounded browser synchronization lab; it does not benchmark engines, replace a durable queue, or guarantee scheduling fairness.

<!doctype html><html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Atomics.waitAsync hostile schedule</title><style>:root{color-scheme:dark}*{box-sizing:border-box}body{margin:auto;max-width:980px;padding:24px;background:#08151d;color:#f6fbff;font:16px/1.5 system-ui}button,a{min-height:44px;padding:10px 14px;font:inherit}button{background:#66e4c5;color:#05120f;border:0;border-radius:8px;font-weight:700}.panel{border:1px solid #8cb3c6;border-radius:14px;padding:16px;margin:16px 0;background:#102631}table{border-collapse:collapse;width:100%}th,td{text-align:left;border-bottom:1px solid #557487;padding:8px}textarea{width:100%;min-height:280px;background:#061016;color:#fff}.scroll{overflow:auto}@media(max-width:540px){body{padding:14px}table{min-width:880px}}@media(prefers-reduced-motion:reduce){*{animation:none!important}}</style><main><h1>Atomics.waitAsync hostile schedule</h1><p>This bounded state-machine lab runs a native shared-memory path only under cross-origin isolation. Its normal-host MessageChannel fallback sends real messages but remains explicitly non-equivalent to shared memory.</p><p id="capability" class="panel" aria-live="polite"></p><button id="run">Run hostile schedule</button><div class="scroll" tabindex="0" aria-label="State-transition table"><table><caption>Observed versioned transitions</caption><thead><tr><th>Case</th><th>Adapter</th><th>Generation</th><th>Expected</th><th>Observed</th><th>Notify count</th><th>Result</th><th>Invariant</th></tr></thead><tbody id="rows"></tbody></table></div><textarea id="receipt" readonly aria-label="Execution receipt"></textarea><p><a id="download" download="atomics-waitasync-receipt.json">Download receipt</a></p></main><script>
const capability={waitAsync:typeof Atomics.waitAsync==='function',sharedArrayBuffer:typeof SharedArrayBuffer==='function',crossOriginIsolated:self.crossOriginIsolated===true,forcedFallback:new URLSearchParams(location.search).get('adapter')==='fallback'};
const policies={timeoutMs:12,cancellation:'AbortSignal removes the pending MessageChannel listener',generation:'accept only messages whose generation equals activeGeneration'},layout={version:1,slots:{state:0,generation:1,payloadVersion:2,publicationState:3}};
const cap=document.querySelector('#capability');cap.textContent='Capability — waitAsync: '+capability.waitAsync+'; SharedArrayBuffer: '+capability.sharedArrayBuffer+'; cross-origin isolated: '+capability.crossOriginIsolated+'; forced fallback: '+capability.forcedFallback;
const sleep=ms=>new Promise(r=>setTimeout(r,ms));
function fixtureWorker(delay,generation,version){const source="self.onmessage=e=>setTimeout(()=>postMessage({generation:e.data.generation,version:e.data.version,payload:'worker-'+e.data.generation}),e.data.delay)";const url=URL.createObjectURL(new Blob([source],{type:'text/javascript'})),worker=new Worker(url);worker.postMessage({delay,generation,version});return{worker,url}}
async function lifecycleRows(adapter){const rows=[],terminated=fixtureWorker(24,1,3);let terminatedMessage=null;terminated.worker.onmessage=e=>{terminatedMessage=e.data};terminated.worker.terminate();await sleep(32);URL.revokeObjectURL(terminated.url);rows.push({case:'termination',adapter,generation:1,expectedValue:2,observedValue:2,notificationCount:0,result:terminatedMessage?'unexpected completion':'terminated; no completion observed',workerTerminationExecuted:true,publicationState:'not-published',invariant:terminatedMessage===null});const activeGeneration=2,events=[],accepted=[],rejected=[],stale=fixtureWorker(28,1,3),restarted=fixtureWorker(5,2,4);await new Promise(resolve=>{const observe=e=>{events.push(e.data);(e.data.generation===activeGeneration?accepted:rejected).push(e.data);if(events.length===2)resolve()};stale.worker.onmessage=observe;restarted.worker.onmessage=observe});stale.worker.terminate();restarted.worker.terminate();URL.revokeObjectURL(stale.url);URL.revokeObjectURL(restarted.url);rows.push({case:'generation-safe-restart',adapter,generation:activeGeneration,expectedValue:4,observedValue:accepted[0]?.version??null,notificationCount:2,result:rejected.length===1?'stale completion observed and rejected':'generation guard failed',workerRestartExecuted:true,staleCompletionObserved:rejected[0]??null,acceptedCompletion:accepted[0]??null,publicationState:'generation-2-published',invariant:accepted.length===1&&accepted[0].version===4&&rejected.length===1&&rejected[0].generation===1});return rows}
async function nativeSchedule(){const view=new Int32Array(new SharedArrayBuffer(16)),rows=[],adapter='Atomics.waitAsync';Atomics.store(view,0,1);Atomics.store(view,2,1);Atomics.store(view,3,1);let record=Atomics.waitAsync(view,0,0,20),observed=Atomics.load(view,0);rows.push({case:'notify-before-wait',adapter,generation:1,expectedValue:0,observedValue:observed,notificationCount:0,result:record.value,publicationState:'payload-v1-published',invariant:record.async===false&&record.value==='not-equal'&&observed===1});record=Atomics.waitAsync(view,0,1,100);let notificationCount=0;setTimeout(()=>{Atomics.store(view,2,2);Atomics.store(view,3,1);Atomics.store(view,0,2);notificationCount=Atomics.notify(view,0,1)},8);const wake=record.async?await record.value:record.value;observed=Atomics.load(view,0);rows.push({case:'asynchronous-wake',adapter,generation:1,expectedValue:1,observedValue:observed,notificationCount,result:wake,publicationState:'payload-v2-published-before-version',invariant:wake==='ok'&&observed===2&&Atomics.load(view,2)===2});record=Atomics.waitAsync(view,0,2,policies.timeoutMs);const timeout=record.async?await record.value:record.value;observed=Atomics.load(view,0);rows.push({case:'timeout',adapter,generation:1,expectedValue:2,observedValue:observed,notificationCount:0,result:timeout,publicationState:'unchanged',invariant:timeout==='timed-out'&&observed===2});const duplicate=Atomics.notify(view,0,2);rows.push({case:'duplicate-notify',adapter,generation:1,expectedValue:2,observedValue:Atomics.load(view,0),notificationCount:duplicate,result:duplicate+' waiters notified',publicationState:'unchanged',invariant:duplicate===0&&Atomics.load(view,0)===2});rows.push({case:'cancellation',adapter,generation:1,expectedValue:2,observedValue:Atomics.load(view,0),notificationCount:0,result:'higher-level cancellation recorded; Atomics wait bounded by timeout',publicationState:'unchanged',invariant:Atomics.load(view,0)===2});return rows.concat(await lifecycleRows(adapter))}
function channelBus(){const channel=new MessageChannel(),queue=[],waiters=[];channel.port1.onmessage=e=>{const index=waiters.findIndex(w=>w.predicate(e.data));if(index>=0){const waiter=waiters.splice(index,1)[0];clearTimeout(waiter.timer);waiter.resolve({status:'message',message:e.data})}else queue.push(e.data)};channel.port1.start();return{send:data=>channel.port2.postMessage(data),take(predicate=()=>true,timeoutMs=policies.timeoutMs,signal){const queued=queue.findIndex(predicate);if(queued>=0)return Promise.resolve({status:'message',message:queue.splice(queued,1)[0]});return new Promise(resolve=>{const waiter={predicate,resolve,timer:0};const finish=status=>{const index=waiters.indexOf(waiter);if(index>=0)waiters.splice(index,1);resolve({status})};waiter.timer=setTimeout(()=>finish('timed-out'),timeoutMs);if(signal)signal.addEventListener('abort',()=>{clearTimeout(waiter.timer);finish('cancelled')},{once:true});waiters.push(waiter)})},close(){channel.port1.close();channel.port2.close()}}}
async function fallbackSchedule(){const adapter='MessageChannel fallback',bus=channelBus(),rows=[];bus.send({generation:1,version:1,payloadVersion:1,publicationState:'published'});const before=await bus.take(m=>m.version===1);rows.push({case:'notify-before-wait',adapter,generation:1,expectedValue:0,observedValue:before.message?.version??null,notificationCount:1,result:before.status,publicationState:before.message?.publicationState,invariant:before.status==='message'&&before.message.version===1});const pending=bus.take(m=>m.version===2,50);setTimeout(()=>bus.send({generation:1,version:2,payloadVersion:2,publicationState:'published-before-message'}),5);const wake=await pending;rows.push({case:'asynchronous-wake',adapter,generation:1,expectedValue:1,observedValue:wake.message?.version??null,notificationCount:1,result:wake.status,publicationState:wake.message?.publicationState,invariant:wake.status==='message'&&wake.message.version===2&&wake.message.payloadVersion===2});const timeout=await bus.take(m=>m.version===99);rows.push({case:'timeout',adapter,generation:1,expectedValue:2,observedValue:2,notificationCount:0,result:timeout.status,publicationState:'unchanged',invariant:timeout.status==='timed-out'});bus.send({generation:1,version:2,duplicate:1});bus.send({generation:1,version:2,duplicate:2});const one=await bus.take(m=>m.duplicate===1),two=await bus.take(m=>m.duplicate===2);rows.push({case:'duplicate-notify',adapter,generation:1,expectedValue:2,observedValue:two.message?.version??null,notificationCount:2,result:'two messages observed; version unchanged',publicationState:'unchanged',invariant:one.status==='message'&&two.status==='message'&&one.message.version===2&&two.message.version===2});const controller=new AbortController(),cancelled=bus.take(m=>m.version===77,100,controller.signal);setTimeout(()=>controller.abort(),4);const cancel=await cancelled;rows.push({case:'cancellation',adapter,generation:1,expectedValue:2,observedValue:2,notificationCount:0,result:cancel.status,publicationState:'unchanged',invariant:cancel.status==='cancelled'});bus.close();return rows.concat(await lifecycleRows(adapter))}
async function execute(){document.querySelector('#run').disabled=true;const nativeAvailable=capability.waitAsync&&capability.sharedArrayBuffer&&capability.crossOriginIsolated&&!capability.forcedFallback,adapter=nativeAvailable?'native':'fallback',transitions=adapter==='native'?await nativeSchedule():await fallbackSchedule(),monotonic=transitions.every((row,index)=>index===0||row.observedValue>=transitions[index-1].observedValue),completionRechecked=transitions.every(row=>row.invariant===true),generationSafe=transitions.at(-1).staleCompletionObserved?.generation===1&&transitions.at(-1).acceptedCompletion?.generation===2,receiptData={fixture:'waitasync-hostile-schedule-v2',capability,adapter,adapterSemantics:adapter==='native'?'shared-memory equality wait':'ordered messages without shared-memory equivalence',layout,policies,transitions,invariants:{monotonic,completionRechecked,generationSafe},benchmark:false};const pass=monotonic&&completionRechecked&&generationSafe;const receipt=document.querySelector('#receipt');receipt.value=(pass?'PASS: ':'FAIL: ')+JSON.stringify(receiptData,null,2);receipt.dataset.execution=JSON.stringify(receiptData);document.querySelector('#rows').innerHTML=transitions.map(r=>'<tr><td>'+r.case+'</td><td>'+r.adapter+'</td><td>'+r.generation+'</td><td>'+r.expectedValue+'</td><td>'+r.observedValue+'</td><td>'+r.notificationCount+'</td><td>'+r.result+'</td><td>'+r.invariant+'</td></tr>').join('');document.querySelector('#download').href=pass?URL.createObjectURL(new Blob([JSON.stringify(receiptData,null,2)],{type:'application/json'})):'';document.querySelector('#run').disabled=false}document.querySelector('#run').onclick=execute;execute();
</script></html>

Audit the synchronization receipt

A useful receipt contains capability state, adapter, isolation status, shared-layout version, generation, expected and observed values, async flag, return string, notify count, timeout policy, cancellation transition, and final invariant checks. Sort transitions by logical sequence rather than wall-clock time so deterministic tests do not pretend to benchmark the scheduler.

For Atomics.waitAsync, require three invariants: versions never decrease, completion follows a state recheck, and a completion generation equals the active generation. Add a fourth if payload slots are used: the producer publishes payload before version. Browser errors and unsupported states belong beside successful transitions.

The semantic transcript below each figure is part of the evidence, not decorative alt text. At narrow widths the timeline becomes a horizontally scrollable data region with a focusable wrapper, while the legend retains worker names and line styles. At 200% zoom a reader can follow rows instead of deciphering tiny arrows. Accessibility makes the protocol easier to debug for everyone.

Progressive coordination matrixwaitAsync, blocking wait, MessageChannel, and server coordination are compared by calling agent, memory, persistence, and failure ownership.primitivecallershared memorypage-close survivalfailure modelwaitAsyncAtomics.waitMessageChannelserver / queue
Progressive coordination matrix
waitAsync, blocking wait, MessageChannel, and server coordination are compared by calling agent, memory, persistence, and failure ownership.
Coordination choices
PrimitiveCallerShared memorySurvives page close
waitAsyncNon-blocking agentRequiredNo
Atomics.waitBlocking-capable workerRequiredNo
MessageChannelWindow or workerNoNo
Server queueNetwork clientNoYes, by contract
Figure 3: The fallback is useful because its differences remain named; it is not mislabeled as shared-memory parity.

Ship the smallest coordination primitive

Use Atomics.waitAsync when agents already share memory, a consumer must await a value transition without blocking, and the team can maintain explicit state invariants. Use blocking Atomics.wait only in a worker whose blocking behavior is intentional. Use messages when ownership and copied payloads are clearer. Use a server or durable queue when work must survive processes, tabs, or devices.

Do not optimize toward shared memory because it sounds lower-level. A MessageChannel often yields a smaller proof surface. A durable queue often yields the right recovery model. Reliable worker synchronization should use the smallest primitive whose failure modes the team can rehearse. Atomics.waitAsync earns its complexity when shared-state coordination is already fundamental and the non-blocking wait closes a real architectural gap.

Take one action before release: run the hostile schedule. Verify notify-before-wait, duplicate notify, timeout, termination, and restart in the supported deployment and in its labeled fallback. Archive the state-transition receipt, not a screenshot of a green badge. Revisit the choice when ECMAScript semantics, browser isolation policy, support, or the worker lifecycle changes.