The Node.js Event Loop Explained
The event loop is the heart of Node.js asynchronous, non-blocking I/O model. Although your JavaScript runs on a single thread, Node can juggle thousands of concurrent connections by offloading slow work to the operating system and processing results as they become ready. Understanding the loop’s phases is what separates code that works from code that scales predictably. This page walks through each phase provided by libuv, how callbacks are queued, and why ordering sometimes surprises people.
The single-threaded execution model
Node.js executes your JavaScript on a single thread. There is exactly one call stack, so two pieces of your code never run literally at the same time. What makes Node feel concurrent is that blocking operations — reading a file, querying a database, waiting on a socket — are handed off to libuv, a C library that uses the OS kernel (epoll, kqueue, IOCP) and a small worker thread pool. When that work finishes, libuv places a callback on a queue, and the event loop later pushes it back onto the single JS thread.
This is why a CPU-bound loop blocks everything: while your synchronous code holds the stack, no timers fire, no I/O callbacks run, and no new connections are accepted.
Keep per-tick work small. A single long synchronous function freezes the entire process — including unrelated HTTP requests sharing the same loop.
The phases of the event loop
Each iteration of the loop (a “tick”) moves through a fixed sequence of phases. Every phase has a FIFO queue of callbacks; the loop drains the queue for the current phase, then advances to the next.
| Phase | What it processes |
|---|---|
| timers | Callbacks scheduled by setTimeout and setInterval whose threshold has elapsed |
| pending callbacks | Certain system-level callbacks deferred from the previous loop (e.g. some TCP errors) |
| idle, prepare | Internal use only by libuv |
| poll | Retrieves new I/O events; executes I/O callbacks (file reads, network data); may block here waiting for work |
| check | Callbacks scheduled by setImmediate |
| close | close event callbacks, e.g. socket.on('close', ...) |
The poll phase is where Node spends most of its time. If there are no timers pending and no setImmediate callbacks scheduled, the loop will block in poll waiting for incoming I/O — this is what makes an idle server consume almost no CPU.
Microtasks run between every phase
Two queues are not part of the phase rotation and have higher priority: the process.nextTick queue and the Promise microtask queue. After each callback completes (and after each phase transition), Node fully drains process.nextTick first, then the Promise microtask queue, before moving on. This is why a resolved Promise’s .then always runs before the next setTimeout.
A lifecycle example
The following script demonstrates the relative ordering of the most common scheduling primitives.
import { readFile } from 'node:fs/promises';
console.log('1: synchronous start');
setTimeout(() => console.log('5: timeout (timers phase)'), 0);
setImmediate(() => console.log('6: immediate (check phase)'));
Promise.resolve().then(() => console.log('4: promise microtask'));
process.nextTick(() => console.log('3: nextTick'));
console.log('2: synchronous end');
readFile(new URL(import.meta.url)).then(() => {
console.log('7: file read completed (poll phase)');
});
Output:
1: synchronous start
2: synchronous end
3: nextTick
4: promise microtask
5: timeout (timers phase)
6: immediate (check phase)
7: file read completed (poll phase)
The synchronous lines print first because they hold the stack. Once the stack clears, microtasks drain (nextTick before Promises). Then the loop begins ticking: the expired timer fires in the timers phase, setImmediate in the check phase, and the file-read callback resolves later once libuv reports the I/O as ready in the poll phase.
timer vs. immediate ordering
A subtle case: when setTimeout(fn, 0) and setImmediate(fn) are scheduled from the main module, their order is not guaranteed — it depends on how long process startup took relative to the 1ms timer minimum. But inside an I/O callback, setImmediate always wins, because the loop is already past the timers phase and will reach check before looping back around.
import { readFile } from 'node:fs/promises';
await readFile(new URL(import.meta.url));
setTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate'));
Output:
immediate
timeout
CommonJS behaves identically — swap the import for const { readFile } = require('node:fs/promises'); the phase semantics are a property of libuv, not the module system.
Best practices
- Keep synchronous work per tick short; offload CPU-heavy tasks to
worker_threadsor a child process so the loop stays responsive. - Prefer
setImmediateoversetTimeout(fn, 0)when you want to yield after the current I/O — it has clearer, deterministic ordering inside I/O callbacks. - Use
process.nextTicksparingly; recursively queuing it can starve the loop and prevent I/O and timers from ever running. - Remember that Promise
.thenandawaitcontinuations run as microtasks, so they execute before any timer orsetImmediate, even one scheduled earlier. - Don’t assume
setTimeout(fn, 0)runs immediately — the loop must finish the current phase, drain microtasks, and reach the timers phase first. - Profile with
--profor theperf_hooksmodule to detect long-running callbacks that stall the poll phase.