How does Node.js's single-threaded event loop actually handle concurrency — walk through its phases
Node runs your code on one thread, yet serves thousands of connections at once — the event loop's six phases and its microtask queue are the whole trick.
LEARN BY DOINGPut your intuition to work3 steps
How does Node.js's single-threaded event loop actually handle concurrency — walk through its phases
Use the lesson’s examples and explanation to check your idea.
Personal reflection. Nothing is sent to an AI service.#One thread, thousands of things happening
JavaScript in Node.js executes application code on a single thread. There is no thread pool running your callbacks in parallel, no mutex to reason about, no data race between two of your own functions. And yet a single Node process routinely handles tens of thousands of concurrent connections. The trick is not parallelism at the JavaScript level — it is that almost none of your code is actually running at any given instant. A request handler reads a file, queries a database, or calls a downstream API, and instead of blocking the thread while that I/O completes, it registers a callback and returns immediately. The thread is freed to do other work, and libuv — the C library underneath Node — handles the actual waiting using the operating system's async I/O facilities (epoll on Linux, kqueue on macOS, IOCP on Windows) or, for work that cannot be made async at the OS level (most filesystem calls, dns.lookup, some crypto functions), a background thread pool of its own (UV_THREADPOOL_SIZE, default 4).
The event loop is the scheduler that decides, once your synchronous code finishes, which of the many pending callbacks runs next. It is not one queue — it is several, organized into ordered phases, each with its own FIFO callback queue, plus a completely separate microtask mechanism that cuts across all of them.
#The six phases
- timers — runs callbacks scheduled by
setTimeoutandsetIntervalwhose threshold has already elapsed. "Elapsed" does not mean "runs at exactly that time," it means "runs no earlier than that time," since the phase only fires when the loop reaches it. - pending callbacks — executes certain system-level callbacks deferred from the previous loop iteration, such as some TCP
ECONNREFUSEDerrors. - idle, prepare — used internally by libuv only; not exposed to application code.
- poll — the workhorse phase: it retrieves new I/O events and runs their callbacks, which is where most file, network, and DNS callbacks fire. If there are no timers due and the poll queue is empty, the loop can block here waiting for new events; if a
setImmediatecallback is queued, the loop stops blocking and moves on immediately. - check — runs
setImmediatecallbacks specifically, right after the poll phase. - close callbacks — runs close-event callbacks, such as a socket's
'close'event handler.
#Microtasks cut across every phase
After every callback returns — not just at the end of a phase, after each individual callback — Node drains two queues before doing anything else: process.nextTick() callbacks first (a Node-specific queue with priority over everything else), then the standard Promise microtask queue (.then continuations, async/await resumptions, queueMicrotask). Both queues are drained completely, including any new entries they themselves schedule, before the loop is allowed to continue. This is why a recursive process.nextTick() chain can, in principle, starve the entire event loop indefinitely — it never lets the loop advance to the next phase, or even to the next macrotask callback.
#setTimeout vs setImmediate: a classic interview trap
At the top level of a module, the order between setTimeout(fn, 0) and setImmediate(fn) is technically unspecified — it depends on how much time has already elapsed since the process started and how fast the timers phase is reached relative to the 1ms-minimum timer threshold, so it can go either way. But inside an I/O callback, the order is deterministic: setImmediate always fires before setTimeout(fn, 0), because the poll phase always transitions directly to the check phase, and the timers phase is not visited again until a full loop iteration later. Interviewers use this to check whether a candidate actually understands the phase ordering or is just pattern-matching "immediate sounds faster than timeout."
#Why this model works for I/O-bound servers and fails for CPU-bound work
Because the event loop is single-threaded, any synchronous, CPU-heavy computation — a large JSON parse, a tight cryptographic loop, an unbatched image transform — blocks that one thread completely. No timers fire, no I/O callbacks run, no HTTP requests are accepted, until that computation returns. This is the single most common Node production incident: a stray synchronous regular expression with catastrophic backtracking, or a hand-rolled CPU-bound loop, freezing an entire process that was otherwise handling thousands of concurrent connections fine. The fix is either to move the work to libuv's thread pool via a native binding, hand it off to a worker_thread, or break it into chunks yielded back to the event loop between pieces.