The question this answers
If one event loop drains every callback in turn, what can still change between the two halves of my handler?
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.
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.
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.
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.
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.
| # | Request A (user 42) | Request B (user 42) | Event loop | State |
|---|---|---|---|---|
| 1 | reads quota.get(42) → 1 | · | · | remaining=1 admitted=0 |
| 2 | checks 1 > 0 → admit | · | · | remaining=1 admitted=0 |
| 3 | await chargeUpstream() — suspends, returns control to the loop | · | · | remaining=1 admitted=0 |
| 4 | · | · | drains the ready queue; B's handler is next | remaining=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 |
| 8 | resumes; 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. |
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.
| Concern | On one event loop | Where the risk moved |
|---|---|---|
| Data race on your own heap | Eliminated — one callback body executes at a time | Only reappears with SharedArrayBuffer across workers (Worker Threads) |
| Race condition across a suspension | Fully present — every await is a yield point | Interleavings of resumed handlers (Await Is a Yield Point) |
| Lock acquisition cost | None on the loop's own state | Replaced by per-key in-flight maps when you need mutual exclusion |
| Blocking a peer | Any synchronous work blocks everyone | Event-loop lag, tail latency on unrelated routes |
| CPU parallelism | None from async alone | Worker threads, worker processes, or a different runtime |
| Memory per concurrent task | A closure and a promise — hundreds of bytes | Was a thread stack (hundreds of KB) in thread-per-connection |
| Debuggability of a failure | Stack traces cross await boundaries poorly | Async task dumps rather than thread dumps |
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 -= 1needs 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.
- • 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_waitand 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.
- • 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.
- • 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,yieldor 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
awaitis 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.
- • 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
fsorcrypto.pbkdf2calls 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.
- • 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
.thenmutates 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.
- • 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.
- • 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.
- • 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
remainingnever goes negative, plus a counter of admissions compared against allowance. The bug is invisible in latency and obvious in accounting.
- • 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.
- • 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
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)
Scheduler timeline
What people believe, and what is true
JavaScript is single-threaded, so I cannot have concurrency bugs.
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.
The event loop schedules my callbacks fairly.
There is no preemption and no quantum. A callback holds the loop until it returns or suspends; a microtask chain can starve timers forever.
Async makes my code faster.
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.