Threadsevent loopcall stacktask queuemicrotaskmacrotask

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.

Runtime-specificBrowserNode.jsCPython
▶ InteractiveInterview question
Progress

The problem

A JavaScript program has one call stack. It calls 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.

A queue and a loop

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.

The life of one asynchronous operation
  1. Call stackyour code calls an async API and returns; the stack unwinds
  2. Async operation registeredthe runtime records the callback / promise and what it is waiting for
  3. Runtime / OS does the waitinga timer wheel; `epoll`/`kqueue`/IOCP for sockets; a thread pool for file I/O
  4. Completionthe timer fires, the socket becomes readable, the pool thread finishes
  5. Task queuethe completion is enqueued as a task (or the promise is resolved, queuing a microtask)
  6. Event loopwhen the stack is empty, dequeue the next task and call its handler
  7. 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.

Predict the output before reading the answer
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 start
14// 2: sync end
15// 3: promise (microtask)
16// 4: nested microtask, still before the timer
17// 5: timeout (task)
18//
19// Why: the script itself is a task; it runs to completion (1, 2). Its
20// 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

Runtime-specific

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, await continuations) 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

The JavaScript event loop
One thread, one call stack, two queues — and a strict rule about which queue drains first.
console.log('A')
setTimeout(() => console.log('B'), 0)
Promise.resolve().then(() => console.log('C'))
console.log('D')
const r = await fetch('/api') // resolves later
console.log('E')
Output so far
Call stack (top = last)
main()
Web APIs (timers, network, DOM)
empty
Microtask queue (promises, await, queueMicrotask)
empty
Task (macrotask) queue
empty
The script starts as one task. main() is on the call stack; nothing else exists yet.
Browser: after each task the loop drains microtasks, then may run rendering (style, layout, paint) before the next task. requestAnimationFrame callbacks run in that render step.
1/11
Runtime-specific

How it fails

What the failure looks like from inside real software.

  • A request handler does JSON.parse on a 30 MB body; every other request in flight waits ~300 ms; p99 latency spikes with CPU at 12%.
  • A while (true) microtask chain (await in a loop that never reaches I/O) starves timers and sockets: the server stops accepting connections without any error.
  • A fs.readFileSync in 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 of await 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.