Async & Event Loops

Event Loops as a Concurrency Model

A queue of ready callbacks drained by one logical execution context, with the runtime performing I/O somewhere else. That shape buys you freedom from data races on your own heap — and buys you nothing at all against race conditions across await points, which is where the bugs actually are.

▶ Run the lab

The question this answers

The question

If one event loop drains every callback in turn, what can still change between the two halves of my handler?

The work

One Node process serving 2,000 open connections, where each request looks up a user's remaining quota, checks it against zero, awaits a downstream call, and writes the decremented value back.

What is shared

A module-level Map of userId → remaining quota, on the loop's own heap. Every handler running on this loop sees the same object identity, mutates it in place, and no other OS thread ever touches it.

The invariant — what must stay true under every interleaving

For each user, remaining equals the starting allowance minus the number of requests actually admitted, and never falls below zero.

Synchronization exists to preserve this sentence. If a schedule can make it false, the code is wrong no matter which primitive it uses.

WorkCan it overlap?Can it parallelise?What is shared?What ordering?What synchronization?Where is contention?What can deadlock?What can race?What is gained?What complexity?

The shape: a ready queue, one drainer, I/O elsewhere

An event loop is a queue of callbacks that are *ready to run*, plus a loop that pulls one off and runs it to completion, then pulls the next. Nothing preempts a callback in the middle. That is the entire model, and everything else about async programming is a consequence of it.

The part people skip is the second half: while the loop is draining callbacks, the actual I/O is not happening on the loop. Socket readiness is reported by the kernel through epoll / kqueue / IOCP; filesystem calls, DNS resolution and several crypto primitives are handed to a real thread pool inside libuv; in the browser, fetches are performed by the network stack outside the page's execution context. The loop's job is to be told "this finished" and to enqueue the continuation. Mechanism lives in The Event Loop and Blocking, Non-blocking, Multiplexed, Asynchronous; this lesson is about what the shape means for your state.

So a process running an event loop is doing several things at once — it is just not *executing your JavaScript* several times at once. Concurrency, yes. Parallelism of your code, no. That distinction is load-bearing enough to get its own lesson in Async Is Not Parallelism.

  • A callback runs to completion or to its next suspension point; the loop never interrupts it mid-statement.
  • Phases matter: timers, pending I/O callbacks, poll, check (setImmediate), close — plus the microtask queue, which is drained to empty between every macrotask.
  • Microtasks (promise continuations, queueMicrotask) starve macrotasks if you keep enqueuing them; a recursive promise chain can prevent a timer from ever firing.
  • One loop per agent: a Node process has one, each worker thread has its own, each browser tab has its own, each web worker has its own.
One loop, one heap, I/O performed elsewhere
readiness → enqueue continuationcompletion → enqueue callbackone callback at a timedrained to empty after each callbackpromise continuations resume heremutates in place, uncontendedSockets (kernel readiness: epoll/kqueue/IOCP)Runtime thread pool (fs, dns, some crypto)Ready queue: macrotasks by phaseEvent loop — one logical execution contextMicrotask queue (promise continuations)Your heap: the quota Map
ClientGateway / LBServiceWorkerDatabaseCacheQueue / LogObject storageCDNExternal system

No data races on this heap — and a race condition anyway

Because only one callback body executes at a time on this loop, two callbacks can never perform conflicting memory accesses simultaneously. There is no data race on the quota Map in the memory-model sense: no torn reads, no unsynchronized concurrent write, no need for a mutex to make remaining -= 1 safe. That is a genuine and large simplification, and it is why event-loop code can share ordinary objects freely where threaded code cannot. See Data Race Is Not Race Condition for the distinction being used here.

What it does not buy you is atomicity of anything spanning an await. The moment your handler suspends, the loop goes and runs other callbacks — including another copy of the same handler, for the same user. Your read is now stale and your write is now based on it. This is a *race condition*: the logical outcome depends on the interleaving, and no memory model anywhere prevents it.

The rule that follows is short and worth memorising: a check and its dependent write must not be separated by an `await`. If they are, either move the decision to a single synchronous region, or make the write conditional on the value you read (compare-and-set style), or hold a lock keyed by the user for the duration. The reasoning is the same as Finding the Critical Section; only the primitive changes.

Two requests for the same user, on one event loop. Nothing runs in parallel; the invariant dies anyway.ILLUSTRATIVE
Invariant · remaining >= 0, and remaining equals allowance minus the number of admitted requests
#Request A (user 42)Request B (user 42)Event loopState
1reads quota.get(42) → 1··remaining=1 admitted=0
2checks 1 > 0 → admit··remaining=1 admitted=0
3await chargeUpstream() — suspends, returns control to the loop··remaining=1 admitted=0
4··drains the ready queue; B's handler is nextremaining=1 admitted=0
5·reads quota.get(42) → 1·remaining=1 admitted=0
6·checks 1 > 0 → admit·remaining=1 admitted=0
7·await chargeUpstream() — suspends·remaining=1 admitted=0
8resumes; writes quota.set(42, 0)··remaining=0 admitted=1
9·resumes; writes quota.set(42, 1 - 1) = 0·remaining=0 admitted=2
✕ Two requests were admitted against an allowance of one; remaining says 0 but should say -1. The second charge is unaccounted for.
A check-then-act split by an await is not atomic, even with exactly one thread. The fix is not a mutex over the Map — nothing is racing on the Map. The fix is to decrement before awaiting (claim first, refund on failure), or to key a per-user in-flight lock, so the check and its dependent write are in one synchronous region.

What the single-loop model actually gives you

It is worth being precise about the trade, because "single-threaded" gets used as a shorthand for both "safe" and "slow", and it is neither. The loop removes one whole class of bug and leaves another entirely intact, while imposing a scheduling constraint that threaded servers do not have.

The constraint: every callback shares one execution budget. A handler that spends 200 ms in a synchronous loop delays every other pending callback by 200 ms, including the ones that were ready long before it started. That is Blocking the Event Loop, and it is the failure mode this model trades for its simplicity.

ConcernOn one event loopWhere the risk moved
Data race on your own heapEliminated — one callback body executes at a timeOnly reappears with SharedArrayBuffer across workers (Worker Threads)
Race condition across a suspensionFully present — every await is a yield pointInterleavings of resumed handlers (Await Is a Yield Point)
Lock acquisition costNone on the loop's own stateReplaced by per-key in-flight maps when you need mutual exclusion
Blocking a peerAny synchronous work blocks everyoneEvent-loop lag, tail latency on unrelated routes
CPU parallelismNone from async aloneWorker threads, worker processes, or a different runtime
Memory per concurrent taskA closure and a promise — hundreds of bytesWas a thread stack (hundreds of KB) in thread-per-connection
Debuggability of a failureStack traces cross await boundaries poorlyAsync task dumps rather than thread dumps
One event loop: what you get, what you do not, what replaces it.

Key points

  • An event loop is a ready queue plus one drainer; a callback runs to completion or to its next suspension point, never interrupted mid-statement.
  • The runtime does I/O elsewhere — kernel readiness for sockets, a real thread pool for filesystem, DNS and some crypto — so the process is genuinely doing several things at once.
  • One loop means no data races on that loop's heap: remaining -= 1 needs no mutex, and ordinary objects can be shared freely.
  • It does not mean atomicity across an await. A check and its dependent write separated by a suspension is a classic check-then-act race with exactly one thread.
  • Microtasks drain to empty between macrotasks, so a self-rescheduling promise chain can starve timers indefinitely.
  • The price of the model is that every callback shares one execution budget: one slow synchronous handler delays every other pending task.

The loop, answered

Every field is required, which is why no lesson here can recommend concurrency without naming the interleaving that breaks it, the complexity it adds, and the simpler thing to consider first.

How it works
  • Your code registers interest in something — a socket read, a timer, a filesystem call — and returns control to the loop instead of waiting.
  • The runtime hands the operation to the appropriate facility: kernel readiness notification for sockets, an internal thread pool for filesystem/DNS/crypto, the browser's network stack for fetch.
  • The loop blocks in a readiness syscall (epoll_wait and friends) when nothing is ready, which is why an idle loop costs no CPU.
  • When an operation completes, its continuation is placed on the appropriate queue — macrotask phase queue or the microtask queue.
  • The loop runs one macrotask to completion, then drains the entire microtask queue, then moves to the next macrotask. Promise continuations are microtasks, so they run before the next timer.
  • Your handler resumes inside that continuation with its local variables restored — and with every piece of shared state possibly changed.
Interleavings that matter
  • A reads quota (1); A awaits; B reads quota (1); B awaits; A writes 0; B writes 0 — two requests admitted against an allowance of one, and the counter cannot even show the discrepancy.
  • A awaits a fetch; a timer callback fires and mutates the same Map; A resumes holding a value that no longer exists in the Map — the update is lost with no error anywhere.
  • A enqueues a microtask that enqueues another microtask, recursively; the loop never reaches the timer phase and a setTimeout(fn, 0) scheduled minutes ago has still not run.
  • A synchronous 200 ms handler runs; twelve socket reads that became ready at t=0 are all delivered at t=200 ms, so their measured latency includes work they had nothing to do with.
  • The schedule where nothing breaks: A reads, checks, decrements, *then* awaits. B now reads the decremented value. Same concurrency, same loop, correct outcome — because the critical section contained no suspension point.
What it guarantees — and does not
  • Guaranteed: no two callback bodies on the same loop execute simultaneously, so no data race on that loop's heap under the JS memory model.
  • Guaranteed: a synchronous statement sequence with no await, yield or callback boundary inside it is atomic with respect to other tasks on that loop.
  • Guaranteed: microtasks enqueued during a task all run before the next macrotask.
  • NOT guaranteed: that state you read before an await is still valid after it. Nothing preserves it.
  • NOT guaranteed: any ordering between two independent async operations. Completion order follows the I/O, not the call order.
  • NOT guaranteed: fairness. A callback that never yields holds the loop for as long as it likes; there is no preemption and no quantum.
  • NOT guaranteed: that the process is single-threaded. libuv's pool, the GC and the browser's compositor are all real threads doing real work.
Where contention appears
  • The loop itself is the contended resource: every ready callback queues behind the currently running one, so contention is measured in queue delay, not in lock waits.
  • Event-loop lag is that queue delay made visible — the gap between when a timer was due and when it actually ran. Track it as a distribution; see Event-Loop Lag: One Callback, Everybody Waits.
  • libuv's thread pool is a second contention point and defaults to a small number of threads; a burst of fs or crypto.pbkdf2 calls serialises behind it while sockets stay fast.
  • Logical contention on shared state does not disappear — it moves into per-key in-flight maps and promise chains, which are locks with worse names.
How it fails
  • Race condition across an await: check-then-act, lost update, double-admit. The dominant bug class in event-loop code.
  • Loop starvation by microtasks: timers and I/O callbacks never get a turn because the microtask queue never empties.
  • Head-of-line blocking: one synchronous CPU burst inflates the latency of every unrelated pending request.
  • Unhandled rejection: a promise nobody awaited rejects, and depending on version and flags the process either warns or exits.
  • Orphaned continuation: the caller returned, the awaited work completes later, and its .then mutates state belonging to a request that is already finished.
  • Silent starvation of the runtime thread pool: filesystem work queues behind other filesystem work with no visible error, only latency.
When it helps
  • Many concurrent connections that spend most of their life waiting: proxies, API gateways, chat servers, anything fanning out to other services.
  • Workloads where per-task memory matters — tens of thousands of in-flight requests as closures, not as thread stacks.
  • Code that genuinely shares mutable structures between tasks and would otherwise need locking discipline the team does not have.
  • UI code, where the requirement is *responsiveness* rather than throughput and the model matches the single rendering context.
When it hurts
  • CPU-heavy work: image processing, large JSON.parse, synchronous hashing, template rendering of big documents. The loop is the only executor and you have taken it.
  • Any dependency that blocks the calling thread — a synchronous database driver, a native module without an async path. One call and the whole server stops.
  • Latency-critical work mixed with variable-duration work in one process; the slow route degrades the fast one and the dashboards blame the wrong service.
  • Teams that read "single-threaded" as "no concurrency bugs" and stop reasoning about interleavings entirely.
How you would know
  • Event-loop lag percentiles, not the mean — sample a timer's scheduled-versus-actual delta and publish p50/p99/max.
  • Longest synchronous task per interval; in the browser, the Long Tasks API reports every block over 50 ms with attribution.
  • Count of pending handles and requests over time — a loop that is busy versus a loop that is backed up look different here.
  • The signal that lies: CPU utilisation of the process. A blocked loop and a healthy loop can both read 100% of one core.
  • For the race specifically: an assertion that remaining never goes negative, plus a counter of admissions compared against allowance. The bug is invisible in latency and obvious in accounting.
Complexity it introduces
  • Every function that awaits becomes part of an async call graph the type system does not police — you must reason about what is reachable during any suspension.
  • Stack traces are cut at await boundaries unless the runtime stitches them; a rejection three layers deep can arrive with no useful frames.
  • Mutual exclusion, when you need it, becomes bespoke: a Map<key, Promise> you wrote by hand, with all the lifecycle bugs of a hand-rolled lock and none of the tooling.
  • The failure mode is timing-dependent, so it reproduces under load and not in tests. Reasoning about the schedule up front is cheaper than reproducing it later.
Simpler alternatives
  • Keep the state out of process memory: put the quota in a database row and decrement it with a conditional UPDATE ... WHERE remaining > 0, letting the storage engine own the atomicity. Usually the right answer once more than one process exists.
  • Thread-per-request with a blocking driver, when the work is CPU-bound or the ecosystem's libraries are synchronous — see Event Loop or Threads?.
  • A per-key serialisation queue (an in-flight promise map) when the state must stay in memory: correct, cheap, and honest about being a lock.
  • Do it once, not concurrently: a single-consumer worker draining a queue removes the interleaving question entirely, at the cost of throughput.

Three I/O calls, one thread

Three I/O calls, one thread
Each request costs 2 ms to dispatch, waits on the network, then costs 5 ms to parse. Nothing here is parallel — there is exactly one thread in both runs.
Event loop
idle — nothing else to run
idle — nothing else to run
idle — nothing else to run
GET /profile
awaiting I/O 120 ms
GET /flags
awaiting I/O 40 ms
GET /orders
awaiting I/O 80 ms
↑ done 261 ms
runningreadywaitingblockedidlems
wall clock
261 ms
CPU actually used
21 ms
thread-time spent waiting
0 ms
threads used
1
sequential   const a = await getProfile(); const b = await getFlags(); const c = await getOrders()
concurrent   const [a, b, c] = await Promise.all([getProfile(), getFlags(), getOrders()])

wall clock   261 ms  →  127 ms       (2.06× less waiting)
CPU used     21 ms  →  21 ms       (identical — no extra core was touched)
261 ms of wall clock to do 21 ms of work. 92.0% of the run is the event loop sitting idle with nothing to do, because each `await` suspends the whole chain before the next request has even been issued. The three calls are independent — nothing in `getFlags()` needs the profile. Flip the toggle and the same thread, the same code path and the same CPU budget finish in 127 ms.
SIMULATEDRUNTIME-SPECIFIC

Scheduler timeline

Scheduler timeline
Tasks over cores, one tick per column. Watch which lanes run, which sit ready, and which are blocked on I/O.
Task 1
ready
ready
blocked
ready
Task 2
ready
ready
blocked
ready
ready
ready
ready
Task 3
ready
ready
ready
ready
ready
blocked
Task 4
ready
ready
ready
ready
Task 5
ready
ready
ready
ready
ready
ready
runningreadywaitingblockedidle1 column = 1 scheduler quantum
running now
1 / 1
ready queue
4
blocked on I/O
0
context switches
0
Ready-queue depth4 waiting for a core
One core: exactly one lane is `running` in every column, yet several tasks advance across the run. That is concurrency without parallelism — the definition, drawn.
A switch is counted whenever a core’s occupant changes between columns; the model charges 0.05 ms for each one. Real switch cost depends on the cache footprint the outgoing task leaves behind and is usually worse than a constant. Mechanism lives in Operating Systems — this view is about what the schedule means.
1/40 · tick 1SIMULATED

What people believe, and what is true

Claim

JavaScript is single-threaded, so I cannot have concurrency bugs.

Reality

JS *execution* is one event loop per agent, and that only removes data races. Every check-then-act split by an await is a race condition, and the runtime does I/O on real threads while workers give real parallelism.

Claim

The event loop schedules my callbacks fairly.

Reality

There is no preemption and no quantum. A callback holds the loop until it returns or suspends; a microtask chain can starve timers forever.

Claim

Async makes my code faster.

Reality

Async lets waiting overlap. If nothing waits, async adds scheduling overhead and nothing else — see Async Is Not Parallelism.

Go deeper

Overview

A queue of ready callbacks, drained one at a time, while the runtime performs I/O elsewhere and enqueues continuations when it finishes.

Practical

Treat every await as a point where any other handler may run. Never split a check from its dependent write across one. Claim the resource before suspending and refund on failure.

Advanced

Microtask versus macrotask ordering decides observable behaviour: a promise continuation always runs before the next timer, so a "yield to the loop" implemented with Promise.resolve() does not actually let I/O in. Use setImmediate (Node) or setTimeout(0) / scheduler.yield() (browser) when you mean it.

Internals

The loop is a readiness-based reactor over epoll/kqueue/IOCP with a thread pool for operations that have no readiness interface. Filesystem I/O has no portable readiness model, which is precisely why it uses the pool and why a burst of fs calls serialises where a burst of socket reads does not.

Apply it