Concurrency, Scheduling, and Distributed Effects
How Node's event loop phases differ from the browser's, backpressure and scheduling fairness, true shared memory with SharedArrayBuffer, structured concurrency, and the realities of distributed systems.
By the end of this lesson, you can
- Explain how Node's event loop phases differ from the browser's single task/microtask model
- Explain what backpressure is and why an unbounded queue is a common production failure mode
- Explain what SharedArrayBuffer and Atomics provide that message passing does not, and their isolation requirements
- Apply structured concurrency principles to ensure a cancelled operation's children are also cancelled
- Explain why a distributed system must tolerate at-least-once delivery and design for idempotency and eventual consistency
Why it matters
Asynchronous JavaScript in Depth covered the browser’s microtask/macrotask model. Systems that genuinely scale — a busy Node server, a multi-worker pipeline, anything talking to another service over a network — need a deeper model: Node’s own event loop phases, true shared memory, disciplined cancellation, and an honest account of what a network actually guarantees.
Node’s event loop phases
Node’s event loop, unlike the browser’s simpler task/microtask model, cycles through distinct phases, each handling a specific category of callback:
| Phase | Handles |
|---|---|
| timers | setTimeout/setInterval callbacks whose delay has elapsed |
| poll | I/O callbacks (file, network) — the loop mostly waits here |
| check | setImmediate callbacks — run right after the poll phase |
| close callbacks | e.g. socket.on('close', …) |
Microtasks (Promise continuations, recall the Event
Loop) still drain completely between every
phase transition — Node layers its own phase structure on top of the same microtask-first
principle, not a replacement for it.
Backpressure and scheduling fairness
Backpressure is what happens when a fast producer overwhelms a slower consumer — recall
Streams’s note about .pipe() handling it automatically. Without it, an
unbounded queue between producer and consumer simply grows without limit:
// No backpressure — if processItem is slower than items arrive, this queue grows forever
const queue = [];
source.on('data', (item) => queue.push(item));
// Bounded — a fixed-size queue applies backpressure by refusing new work until there's room
class BoundedQueue {
#items = [];
#maxSize;
constructor(maxSize) {
this.#maxSize = maxSize;
}
async push(item) {
while (this.#items.length >= this.#maxSize) {
await new Promise((resolve) => setTimeout(resolve, 10)); // wait for room
}
this.#items.push(item);
}
}
Starvation is the related failure where one task never gets scheduled at all, because higher-priority or more frequent work keeps consuming the loop’s attention — a risk in any system with more than one priority level of scheduled work, worth testing for explicitly under load, not just assumed away.
True shared memory: SharedArrayBuffer and Atomics
Recall from Web Workers that postMessage copies data — no
sharing. SharedArrayBuffer is the deliberate exception: a genuine block of memory multiple
threads (workers) can read and write directly, with Atomics providing race-free operations on it:
const sab = new SharedArrayBuffer(4);
const view = new Int32Array(sab);
// In one worker:
Atomics.add(view, 0, 1); // atomic increment — safe even if another thread does this simultaneously
// In another worker sharing the same sab:
Atomics.load(view, 0); // reads the current value safely
Structured concurrency
Structured concurrency means a concurrent operation’s lifetime is bound to its parent’s — when the parent is cancelled, every child operation it started is cancelled too, with nothing left “dangling” independently:
async function processAll(items, signal) {
const controller = new AbortController();
signal.addEventListener('abort', () => controller.abort()); // parent cancellation propagates to children
try {
await Promise.all(items.map((item) => processItem(item, controller.signal)));
} finally {
controller.abort(); // ensure no child outlives this function, success or failure
}
}
Distributed realities
Once a network is involved, guarantees change fundamentally. A request can fail after the server successfully processed it, purely because the response was lost — this ambiguity is unavoidable, not a bug to fix:
| Assumption | Reality |
|---|---|
| A request either fully succeeds or fully fails, and the caller always knows which | False — a lost response looks identical to a lost request from the caller's side |
| Retrying is always safe | Only for an idempotent operation — recall idempotent from Asynchronous JavaScript in Depth |
| Two systems agree on data immediately after a write | Often false — many distributed stores are eventually consistent, converging after a delay |
| Messages arrive in the order sent | Not guaranteed by many transports — explicit sequencing is needed if order matters |
Because a caller can never distinguish “the request never arrived” from “the response never arrived,” a reliable distributed system is designed around at-least-once delivery as the realistic baseline — meaning every operation that could be retried must be idempotent, or carry a deduplication mechanism (an idempotency key) that makes a duplicate delivery harmless.
Check your understanding
Or reveal the answer without checking
Answer:The process eventually runs out of memory, since nothing is applying backpressure to slow the producer down
With no backpressure mechanism, an unbounded queue simply grows for as long as the producer outpaces the consumer — eventually exhausting available memory. A bounded queue (or a mechanism like .pipe()'s automatic backpressure) is what prevents this.
Or reveal the answer without checking
Answer:It genuinely cannot know from this alone — the server may have processed it before the response was lost, so a safe retry requires an idempotency mechanism
A lost response is indistinguishable, from the client's perspective, from a lost request or a server that failed before processing — the operation's actual outcome is genuinely unknown. Blind retry is unsafe for a non-idempotent operation like a payment; an idempotency key (or checking the actual current state) is needed to retry safely.
Practice: warm-up
Explain why postMessage between a main thread and a worker is safe by default (no data races
possible), while SharedArrayBuffer + Atomics requires deliberate care to avoid one.
Stuck? Reveal one hint at a time.
Hint 1
postMessage copies data via structured clone — recall Web Workers — so each side always has its own independent copy, never the same memory.
Hint 2
SharedArrayBuffer is the deliberate exception: both sides genuinely read and write the exact same memory, which is precisely what makes a race between two simultaneous writes possible without Atomics.
Reveal the explanation
Try the problem yourself before reading this. There is often more than one reasonable approach — treat this as one worked example, not the only correct answer.
postMessage always copies data (via structured clone) — each side ends up with its own
independent copy, so there’s no shared memory location either side could race on. SharedArrayBuffer
is specifically the mechanism that removes that copying, giving both sides genuine access to the
same underlying memory — which means an uncoordinated read-modify-write from two threads
simultaneously can interleave incorrectly. Atomics operations are specified to be indivisible
(atomic), preventing that specific class of race for the operations they cover.
Practice: apply it
Sketch (as code or clear pseudocode) how you would modify a function that fetches and processes a list of URLs concurrently so that cancelling the overall operation also cancels every still-pending individual fetch — structured concurrency, not independently-running children.
Stuck? Reveal one hint at a time.
Hint 1
A single AbortController created for the whole operation, with its signal passed into every individual fetch call, is the core of the pattern.
Hint 2
Aborting that one controller — whether from an external cancellation or a caught error — cancels every fetch still using its signal.
Reveal a reference solution
Try the problem yourself before reading this. There is often more than one reasonable approach — treat this as one worked example, not the only correct answer.
async function fetchAll(urls, externalSignal) {
const controller = new AbortController();
externalSignal?.addEventListener('abort', () => controller.abort());
try {
return await Promise.all(
urls.map((url) => fetch(url, { signal: controller.signal }).then((r) => r.json())),
);
} finally {
controller.abort(); // ensure every fetch is cancelled, whether we succeeded, failed, or were cancelled
}
}Summary
- Node’s event loop cycles through distinct phases (timers, poll, check, and others); microtasks still drain completely between every phase transition, layered on top of that structure.
- Backpressure prevents a fast producer from overwhelming a slower consumer via an unbounded queue — a common real production failure mode when absent; starvation is the related risk of a task never getting scheduled under contention.
SharedArrayBuffer+Atomicsprovide genuine shared memory between workers, unlikepostMessage’s always-copied data — and require cross-origin isolation headers, a deliberate security response to Spectre-class attacks.- Structured concurrency means cancelling a parent operation must explicitly propagate to every
child it started — this doesn’t happen automatically without deliberate
AbortControllerwiring. - A distributed system must be designed around at-least-once delivery as the realistic baseline — every retryable operation needs to be idempotent, since a caller can never reliably distinguish a lost request from a lost response.