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.

COOP / COEP required

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.

One handoff: park on a slot, publish the payload, wake the sleeper Three vertical lanes. The main thread creates a SharedArrayBuffer and posts the same buffer to both workers in one step. The consumer loads the signal slot, sees the pending value, and calls Atomics.wait, which suspends it — drawn as a tall solid bar on its lifeline during which the agent runs no code and cannot receive a postMessage. Meanwhile the producer writes the data slot, then stores READY into the signal slot, then calls Atomics.notify with a count of one; an arrow runs from that notify back to the end of the consumer's parked bar. The consumer's wait returns the string ok, it re-tests the loop condition, loads the data slot and posts the result back to the main thread. A panel underneath shows the opposite interleaving: the producer stores and notifies before any waiter exists, that notify is dropped because nothing is queued, the consumer's load then reads READY directly, and Atomics.wait is never called at all — so there is no window in which a wake-up could go missing. One handoff: park on a slot, publish the payload, wake the sleeper Main thread Consumer worker Producer worker new SharedArrayBuffer(16) postMessage({ type: 'INIT', sab }) — the same buffer to both workers Atomics.load(SIGNAL_SLOT) → PENDING still pending, so it parks Atomics.wait(SIGNAL, PENDING, 2000) parked — the agent is suspended; it cannot even receive a postMessage 1 · view[DATA_SLOT] = 42 2 · Atomics.store(SIGNAL, READY) 3 · Atomics.notify(SIGNAL, 1) → 1 wakes the one parked agent wait() returns 'ok' permission to look, not proof — re-test Atomics.load(DATA_SLOT) → 42 postMessage RECEIVED The two INIT messages have no required order: the load-before-wait loop is what makes the arrival order irrelevant. The race that is not a race — the notify fires before the consumer parks 1 · the producer stores READY and calls notify(1) with nobody parked, so that wake-up is simply dropped 2 · the consumer arrives afterwards and its Atomics.load already reads READY, so the loop body never runs 3 · Atomics.wait is never called, so there is no window in which a notify could have gone missing
The solid bar on the consumer's lifeline is real suspension, not idling: no timers, no message events, no stack activity. Everything that makes the handoff safe happens on either side of it — the load that decides whether to park at all, and the re-test that decides whether the wake meant anything.

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 Int32Array views over the same SharedArrayBuffer at 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 wait is restricted; notify never 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 of 0.
  • { 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.

Same wait queue, two very different timelines Two panels. On the left, a dedicated worker whose CanBlock flag is true: its execution bar runs, then turns into a wide solid block labelled parked, during which no instructions run and a postMessage sent to it only queues; an arrow marked Atomics.notify lands on the end of that block and the bar resumes. On the right, the main thread running Atomics.waitAsync: its timeline is an unbroken row of event-loop tasks — input, requestAnimationFrame, a long task and a render — and the same notify arrow lands in the middle of the long task. A dashed line runs from that point to the end of the task, where a dot marks the microtask checkpoint at which the promise finally resolves, showing that a long synchronous task delays the resolution rather than the wake itself. Same wait queue, two very different timelines Atomics.wait — dedicated worker [[CanBlock]] = true · the agent stops Atomics.notify running parked — no instructions run resumes time → · the event loop of this agent is not running · a postMessage to it queues, undelivered · only terminate() can end an untimed wait · wake latency ≈ one scheduler round trip Use it when the worker has nothing else to do a parked worker costs no CPU at all Atomics.waitAsync — any agent never blocks · resolves a promise instead Atomics.notify input rAF long task render time → notify arrives mid-task — the promise cannot resolve yet it resolves at the checkpoint after the task ends · legal on the window, shared and service workers · same queue and same notify as the blocking form · a long task delays the resolution, not the wake Use it when the agent must stay responsive it is not a faster postMessage — same floor Both forms join the same wait queue on the same slot; the difference is only whether the calling agent stops.
The notify is identical in both panels — one call, one wait queue. What differs is what the waiting side does with the time in between: the worker gives up its thread, while the main thread keeps draining tasks and picks the answer up at the next microtask checkpoint.

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.

One slot, two states — and a queue for everyone who lost the race The lock slot has exactly two states: UNLOCKED, holding zero, and LOCKED, holding one. A successful compareExchange from zero to one moves the slot to LOCKED and gives worker A the lock without any parking. Unlock runs the reverse compareExchange from one to zero and then calls Atomics.notify with a count of one. Below, the contended path: worker B's compareExchange returns one because the lock is already held, so it calls Atomics.wait, which parks it only while the slot still reads one, and it joins the wait queue for that slot behind workers C and D. When the holder unlocks, the notify reaches only the agent at the front of the queue — worker B — which wakes, retries the compare-and-exchange and loops back to the start; workers C and D stay parked, so no thundering herd of failed retries occurs. One slot, two states — and a queue for everyone who lost the race UNLOCKED slot value 0 LOCKED slot value 1 · exactly one owner compareExchange(0 → 1) succeeds worker A takes the lock — no park, no syscall unlock(): compareExchange(1 → 0), then Atomics.notify(slot, 1) Contended path — worker B loses the race B: compareExchange(0 → 1) → 1 the lock is already held B: Atomics.wait(slot, LOCKED, 1000) parks only while the slot still reads 1 B: woken → retry the CAS the for(;;) loop closes the gap wait queue for this one slot worker B — at the front worker C — still parked worker D — still parked count 1 wakes the front agent only — C and D stay parked notify(slot, 1) Waking one waiter instead of all is what keeps the contended path from becoming n − 1 wasted CAS attempts.
The left column is one iteration of 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.

A latch fires once: notify wakes everyone, and late arrivals never wait Four horizontal lanes against a single time axis. The firing agent works on setup for a while, then at one instant stores FIRED into the latch slot and calls Atomics.notify with the default count of Infinity; that instant is drawn as a dashed vertical line crossing every lane. Workers A and B each run briefly, then park in awaitLatch for the rest of the interval; both are marked with a dot where the dashed line meets them, showing that a single notify releases all of them at once, and both resume together. Worker C does not exist until after the firing instant: when it finally runs, its Atomics.load of the latch slot already reads FIRED, so it returns immediately and never calls Atomics.wait. The latch keeps the FIRED value forever, which is what makes that late read work. A latch fires once: notify wakes everyone, and late arrivals never wait fire(): Atomics.store(LATCH, FIRED) + Atomics.notify(LATCH) — count defaults to Infinity Firing agent Worker A Worker B Worker C (late) loading the dataset latch stays FIRED forever running parked in awaitLatch() resumes — load() sees FIRED, returns true running parked in awaitLatch() resumes on the very same notify not created yet load() sees FIRED — never parks time → Because the latch never resets, the load-before-wait check is what lets a worker that starts late still observe it.
The dots mark one 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;
It is not main thread versus worker — it is the agent's [[CanBlock]] flag A matrix with one row per agent type and one column per operation. The window agent, the shared worker and the service worker all have CanBlock set to false, so Atomics.wait throws a TypeError in each of them; only the dedicated worker has CanBlock set to true and may block. Atomics.waitAsync and Atomics.notify are allowed in all four agents, because neither of them ever suspends the calling agent. The shared worker row is the one that catches people out, because it is not the main thread yet still cannot block. It is not main thread vs worker — it is the agent's [[CanBlock]] flag Agent [[CanBlock]] Atomics.wait Atomics.waitAsync Atomics.notify Window (the main thread) the similar-origin window agent false throws TypeError allowed allowed Dedicated worker the only agent that may block true allowed allowed allowed Shared worker not the main thread, still cannot block false throws TypeError allowed allowed Service worker event-driven and must stay killable false throws TypeError allowed allowed waitAsync and notify never block, so they are legal in every agent; only wait needs [[CanBlock]] = true.
Only the highlighted row can call 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

Rule of thumb

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.

Frequently Asked Questions

Why does Atomics.wait throw a TypeError on the main thread?
Because Atomics.wait blocks the agent that calls it, and blocking the main agent would freeze everything the browser drives from it — input handling, requestAnimationFrame, layout, and any other event that has to run before the wake can possibly arrive. The specification models this with the [[CanBlock]] field of the agent record: it is true for a dedicated worker and false for the main (similar-origin window) agent, and calling wait where it is false throws a TypeError. Use Atomics.waitAsync instead, which never parks the thread and instead resolves a promise from the same wait queue. Note that a shared worker and a service worker also have [[CanBlock]] set to false, so the same restriction applies there.
Can a notify be lost, leaving a worker parked forever?
Yes, if you signal on one memory location and park on a different one, or if you park unconditionally without re-reading the state. Atomics.notify only wakes agents that are already in the wait queue for that exact index — it is a no-op with no memory, so a notify that fires in the window between a waiter deciding to sleep and actually sleeping is simply dropped. The fix is structural, not a timing hack: wait on the very slot whose value encodes the condition, and pass the value you just loaded as expectedValue. If the writer changed the slot in that window, wait returns "not-equal" immediately instead of parking. Always pass a finite timeout as well, so a genuine protocol bug degrades into a slow loop rather than a hung thread.

See also