The idea: one thread, many queues

Node.js runs your JavaScript on one thread. It still serves thousands of connections at once, because nothing in JavaScript waits: reading a file, a timer or a network request only registers a callback and returns. The event loop decides which waiting callback runs next. Everything on this page is about that decision.

One loop iteration runs six phases in order: timers, pending, idle and prepare, poll (epoll_wait, then I/O callbacks), check (setImmediate) and close; then if anything is still alive the loop goes back to timers, otherwise uv_run returns and the process exits. After main() and after every callback the nextTick queue and then the promise queue are drained
The loop cycles through six phases; between any two callbacks the nextTick and promise queues are emptied first.

The canvas has three bands. The top band is the JavaScript thread: the program, the call stack, the two microtask queues and the console. The middle band is libuv, the C library that runs the loop, with its six phases and the callbacks waiting in each. The bottom band is what runs outside the JavaScript thread: libuv's thread pool and the kernel's epoll.

The call stack and run-to-completion

A callback runs from start to end without being interrupted. While it runs, nothing else in JavaScript can run: not a timer that is due, not a response that has arrived. The loop only picks the next callback when the call stack is empty. The first thing on the stack is main(), the script itself; the loop starts only after it returns.

Microtasks: the nextTick queue and the promise queue

Two queues belong to Node and V8, not to libuv. They are drained after main() and after every single callback the loop runs (since Node 11; before that, after each whole phase).

APIQueueRuns
process.nextTick(fn)nextTick queue (Node)first, as soon as the current callback returns
promise.then(fn), code after await, queueMicrotask(fn)microtask queue (V8)after the nextTick queue is empty

The drain is a loop: run every nextTick (including ones added meanwhile), then let V8 run every promise job (including ones added meanwhile), and repeat until both are empty. So a nextTick queued from inside a promise job waits until the whole promise queue is done. Demo: nextTick vs promise nesting prints n1, p1, p2, p3, n2.

The six phases of a loop iteration

PhaseWhat runs
timersevery setTimeout / setInterval callback whose time has come, earliest first
pendingI/O callbacks that libuv deferred from the last iteration (for example some TCP errors)
idle, preparelibuv internals; no JavaScript
pollwait for I/O in epoll_wait, then run the callbacks of the ready sockets and finished thread-pool jobs
checksetImmediate callbacks queued before the phase began
close'close' callbacks of handles closed in this iteration, such as a destroyed socket

After close, the loop checks whether anything is still alive: a timer, an open socket or server, a thread-pool request, a waiting immediate or a closing handle. If nothing is, uv_run() returns and the process exits. The label in the libuv band always shows what is keeping the loop alive. Demo: HTTP server lifetime ends when the server is closed and the last timer has fired.

Poll: how long the loop sleeps

Poll is the only place where the thread sleeps. Before calling epoll_wait, libuv computes a timeout:

  • 0 if immediates are waiting or handles are closing: there is work to do right after poll.
  • otherwise the time until the next timer is due;
  • otherwise −1: block until some fd is ready.

Network sockets are non-blocking fds watched by epoll, so a socket costs no thread while it waits. In Demo: HTTP server lifetime the poll timeouts are 9, 7, 6, 0, 4 and 0 ms: each one is the gap to the 10 ms timer, except when a closing handle forces 0. nginx is built on the same kind of loop; see How nginx Handles an HTTP Request.

The thread pool

Some work has no non-blocking kernel interface that epoll can watch: regular files, dns.lookup (which calls getaddrinfo), CPU-heavy crypto and zlib. libuv runs these on a thread pool of 4 threads (UV_THREADPOOL_SIZE, up to 1024). A finished job writes to an eventfd that epoll watches, and its callback runs on the JavaScript thread in the next poll phase. With more jobs than threads, the rest wait: in Demo: thread pool saturation four hashes finish at 20 ms and the fifth at 40 ms, while network data is handled at 5 ms because sockets never use the pool.

setTimeout(fn, 0) vs setImmediate(fn)

setTimeout(fn, 0) is really 1 ms. From the main script, whether it runs before or after setImmediate depends on whether 1 ms has passed by the time the first timers phase starts, which depends on how fast the machine starts up. Demo: setTimeout 0 vs setImmediate race runs the same two lines with 1 ms and with 0 ms of startup overhead and gets both orders. Inside an I/O callback there is no race: check comes straight after poll, so the immediate always runs first (Demo: inside an I/O callback).

Blocking and starvation

A timer's delay is a minimum. In Demo: blocking the loop a 50 ms synchronous loop in main() delays a 10 ms timer to 51 ms, and a response that arrived at 5 ms is handled at 51 ms. The kernel received it on time; the JavaScript thread was busy.

Timeline from 0 to 60 ms: main() keeps the JavaScript thread busy from 0 to 50 ms; a response arrives at 5 ms and a timer is due at 10 ms, both wait, and both callbacks run only at 51 ms, timer first
While main() runs for 50 ms, the response from 5 ms and the timer due at 10 ms just wait; both callbacks run at 51 ms.

Microtasks can block the loop too, because the drain only ends when both queues are empty. A function that keeps rescheduling itself with process.nextTick never lets the loop reach the timers phase. The same function using setImmediate runs once per iteration, so timers and I/O get their turn in between. Demo: nextTick starves, setImmediate yields shows the timer at 6 ms and then at 2 ms.

The fixes: split long work into chunks with setImmediate, move CPU-heavy work to worker_threads or another process, and never busy-wait.

Browser vs Node

Browser (HTML spec)Node.js (libuv)
Macrotaskstask queues (timers, events, messages); one task per loop turnsix phases, each running all of its ready callbacks
Microtaskspromise jobs, queueMicrotask, MutationObservernextTick queue first, then promise jobs
Extra stepsrequestAnimationFrame, style, layout, paint (about every 16 ms)setImmediate (check phase), 'close' callbacks
Waiting for I/Oinside the browser, not visible to the pageepoll / kqueue / IOCP in the poll phase

What the page leaves out

Timers are really kept in one list per duration inside a priority queue, and setInterval, unref() and refresh() are left out. Since libuv 1.45 (Node 20) the timers are run at the end of each iteration, after close, plus once before the first iteration; the order of the phases in the cycle is the same. The TCP handshake, the HTTP parser, backpressure, queueMicrotask, unhandled rejections, async_hooks, worker_threads, and the macOS (kqueue) and Windows (IOCP) back ends are left out too. JavaScript is modelled as taking no time, except for busy(n).

See also Linux epoll for what happens inside epoll_wait, nginx for another single-threaded event loop, and Tomcat for the thread-per-request design it replaces.