The Event Loop
An event loop is a single thread that repeatedly takes the next completed event from a queue and runs its handler to completion; the runtime and the OS do the waiting elsewhere, so one thread can hold thousands of in-flight operations as long as no handler blocks it.
The problem
fetch() for ten URLs, and none of them has a response yet. If the stack cannot be suspended halfway through a function, where do those ten pending operations live, who notices when a response arrives, and how does the right code get called with it?Progressive depth
The same mechanism at different altitudes — start where you are.
Async calls return immediately. When their results arrive, they are placed on a queue. A loop takes one item at a time and runs the code waiting for it. One thread, many pending operations.
One call stack, many waits
A call stack can represent exactly one chain of calls in progress. A function that wants to wait for a network reply has two options: block the thread (the C read() approach, which freezes everything else on that thread) or return immediately and arrange for a *different* function to be called later with the result. The event loop is the machinery for the second option. Every asynchronous API — setTimeout, fetch, fs.readFile, a socket on("data") — registers a callback (or a Promise resolver, which is a callback with a nicer interface), hands the actual waiting to the runtime or the OS, and returns.
The stack unwinds to empty. Now the loop runs: it looks at its queues for a completed event — a timer that expired, a socket that became readable, a file read the thread pool finished — pulls the first one, and calls its handler. The handler runs *to completion*: nothing interrupts it, and the loop looks at the queues again only when it returns and the stack is empty again. That run-to-completion guarantee is what makes a single-threaded loop race-free within a realm, and it is also what makes a slow handler catastrophic.
- Call stackyour code calls an async API and returns; the stack unwinds↓
- Async operation registeredthe runtime records the callback / promise and what it is waiting for↓
- Runtime / OS does the waitinga timer wheel; `epoll`/`kqueue`/IOCP for sockets; a thread pool for file I/O↓
- Completionthe timer fires, the socket becomes readable, the pool thread finishes↓
- Task queuethe completion is enqueued as a task (or the promise is resolved, queuing a microtask)↓
- Event loopwhen the stack is empty, dequeue the next task and call its handler↓
- Call stackthe handler runs to completion; then microtasks; then the next task
Tasks, microtasks and promises
JavaScript has two kinds of queue, with a strict priority between them. Tasks (often called macrotasks) are timers, I/O completions, setImmediate in Node, UI events in the browser, postMessage. Microtasks are promise reactions (.then, await continuations), queueMicrotask, and MutationObserver callbacks. The rule: after every task — and in fact after every callback the runtime invokes — the loop drains the *entire* microtask queue, including microtasks queued by microtasks, before taking the next task. Promises therefore always run before timers, no matter the order they were created in, and a microtask that keeps queueing microtasks starves timers and I/O forever.
async/await is syntax over this: await x splits the function at that point, schedules the remainder as a microtask when x settles, and returns a promise to the caller. Awaiting an already-resolved value still yields — the continuation runs as a microtask, not synchronously — which is why an await inside a loop still lets other microtasks interleave.
1console.log('1: sync start')2 3setTimeout(() => console.log('5: timeout (task)'), 0)4 5Promise.resolve().then(() => {6 console.log('3: promise (microtask)')7 Promise.resolve().then(() => console.log('4: nested microtask, still before the timer'))8})9 10console.log('2: sync end')11 12// Output, in every spec-compliant engine:13// 1: sync start14// 2: sync end15// 3: promise (microtask)16// 4: nested microtask, still before the timer17// 5: timeout (task)18//19// Why: the script itself is a task; it runs to completion (1, 2). Its20// microtask queue is then drained fully (3, then the microtask 3 queued: 4).21// Only then does the loop pick the next task — the expired 0 ms timer (5)."Single-threaded" — precisely
The correct sentence is: *the JavaScript of one realm executes on one thread, one task at a time*. The runtime around it is not single-threaded. In Node, the loop thread waits in epoll_wait/kevent/GetQueuedCompletionStatus for sockets and timers, while a libuv pool of 4 threads performs file I/O, getaddrinfo, zlib and some crypto, and V8 runs garbage collection and optimising compilation on helper threads. In a browser, networking, decoding, layout and compositing live on other threads and, in Chrome, other processes. The completions of all of that are what fill the task queue. worker_threads and Web Workers add more realms — more loops on more threads — that share memory only through SharedArrayBuffer.
Python’s asyncio is the same architecture with different names: the loop thread waits in selectors.select (epoll/kqueue) or IOCP on Windows, coroutines suspend at await, and run_in_executor moves blocking calls to a thread pool. There is only one queue level (ready callbacks), no microtask distinction, and await on a finished future does yield to the loop like JavaScript. C++ reactors (Asio, libuv) and Rust’s Tokio are the same shape with explicit thread counts. The abstraction is universal; the queue priorities and what runs off-loop are runtime specifics — see How C++, JavaScript and Python Map onto the OS.
Blocking the loop
Because a handler runs to completion, everything the loop owns waits behind it: every other request’s continuation, every timer, every incoming connection. A 200 ms synchronous JSON parse of a large body delays 200 ms of other people’s requests; a synchronous fs.readFileSync in a request handler stalls the loop for the disk latency; a tight microtask loop stalls it forever. The tell-tale signature is high latency with low CPU — or, for CPU-bound handlers, exactly one core at 100% while the others idle.
Node exposes the loop’s health directly: perf_hooks.monitorEventLoopDelay() histograms how late timers fire, and a p99 loop delay in the hundreds of milliseconds is a blocked loop. The fixes are the ones from Threads versus Async versus Processes: move CPU work to a worker or another process, replace sync calls with async ones, and chunk long computations with setImmediate so the loop can breathe between pieces.
Key points
- An async API registers a callback, hands the waiting to the runtime/OS and returns; the loop calls the callback later, when the stack is empty.
- Handlers run to completion; that is why one realm is race-free and why a slow handler delays everything.
- Microtasks (promise reactions,
awaitcontinuations) drain completely after every task, before the next task;setTimeout(…, 0)therefore runs after every pending promise. - The JS of one realm is single-threaded; the runtime is not — libuv’s pool, V8 helpers, the browser’s network stack, and epoll/kqueue/IOCP do the waiting.
- Blocked-loop signature: latency up, CPU idle (or one core pegged); measure it with event-loop delay.
Why does this exist?
Mechanisms are answers to constraints. Open each question before reading the answer.
▸Why do event loops exist?
To hold many in-flight operations without a kernel thread per operation: the OS can report "socket ready" for thousands of descriptors in one call, and a loop turns each report into a function call on one thread.
▸Why are microtasks separate from tasks?
So that promise continuations run promptly, in program order, before any unrelated I/O or timer can interleave — an ordering guarantee that makes await-based code reason like sequential code.
▸Why run handlers to completion instead of preempting them?
Preemption would reintroduce every data race that threads have; run-to-completion makes all state mutation within a realm effectively atomic between yields.
The JavaScript event loop
console.log('A')setTimeout(() => console.log('B'), 0)Promise.resolve().then(() => console.log('C'))console.log('D')const r = await fetch('/api') // resolves laterconsole.log('E')
How it fails
What the failure looks like from inside real software.
- A request handler does
JSON.parseon a 30 MB body; every other request in flight waits ~300 ms; p99 latency spikes with CPU at 12%. - A
while (true)microtask chain (awaitin a loop that never reaches I/O) starves timers and sockets: the server stops accepting connections without any error. - A
fs.readFileSyncin a hot path pins the loop for each disk read; replacing it with the async version raises throughput 10× without touching the disk. - A Python asyncio handler calls
time.sleep(1)instead ofawait asyncio.sleep(1); every coroutine on the loop stalls one second. - A browser page runs a synchronous 3-second computation; the tab stops responding to clicks and the browser offers to kill it.