The Node.js Event Loop: A Mental Model Before the Deep Dive

Before dissecting each phase of the event loop, you need a map. Here is the full picture -- V8, libuv, and every phase -- in one place.

June 4, 20267 min read1 / 2

When I first read that Node.js has an "event loop," I pictured something like a while loop running callbacks. That picture is accurate enough to use Node.js. It is not accurate enough to understand it.

The event loop is not one loop. It is a sequence of distinct phases, each with its own queue and its own rules about what it will and will not execute. Before any of the phases are explained in depth, it helps to have the whole map in front of you.

Two Libraries, One Runtime

Node.js is two separate things working together.

V8 runs your JavaScript. It takes the source code, interprets it, compiles hot paths to machine code, and manages the heap. When you call a function, V8 executes it.

libuv handles everything that touches the outside world. File reads, DNS lookups, TCP connections, timers, UDP sockets -- all of it goes through libuv. It is a C library that wraps the operating system's asynchronous I/O interfaces.

The event loop lives in libuv. It is the bridge between your JavaScript code and the operating system.

V8 executes JavaScript; libuv manages I/O. The event loop is libuv's scheduling mechanism that connects the two. ExpandV8 executes JavaScript; libuv manages I/O. The event loop is libuv's scheduling mechanism that connects the two.

The Initial Phase: Main Module

Before the event loop starts, the main module runs. This is the synchronous code at the top level of your entry file -- variable declarations, require calls, function definitions, any synchronous computation.

This phase runs once and blocks everything else. If you do expensive synchronous work at the top of your file -- reading a large config, doing heavy computation -- the event loop cannot start until it finishes.

JavaScript
// This entire block runs in the main module phase const config = JSON.parse(fs.readFileSync('./config.json', 'utf8')); // synchronous const server = http.createServer(handler); // just registers the handler server.listen(3000); // registers a listener; actual listening happens in the event loop

Only after this synchronous execution completes does the event loop begin.

The Phases

Once the main module finishes, the event loop cycles through phases in order, repeatedly, until there is nothing left to do.

1. Timers

This is where setTimeout and setInterval callbacks execute -- but only if their delay has elapsed.

The critical thing to understand: a timer's delay is a minimum, not a guarantee. When you write setTimeout(fn, 1000), you are saying "do not run this before 1000ms." You are not saying "run this in exactly 1000ms." If the event loop is busy in another phase when the timer fires, the callback waits.

If enough work piles up, a timer can be delayed by minutes. The delay argument is a lower bound, not an upper bound.

2. Pending Callbacks

Some I/O callbacks from the previous loop iteration are deferred to this phase rather than the poll phase. The most common case: TCP error callbacks. When a TCP connection receives an error, the callback does not fire immediately -- it is queued here.

3. Idle and Prepare

An internal phase. Node uses it to set up state needed before polling for I/O. You will not interact with this phase directly, but it is part of why certain things behave the way they do when the event loop is under load.

4. Poll

This is the most important phase for I/O-heavy Node applications.

The poll phase does two things. First, it processes all currently ready I/O callbacks -- everything that has arrived since the last poll. Second, if there are no timers ready and no setImmediate callbacks pending, it blocks here and waits for new I/O events.

This is how Node achieves non-blocking I/O without threads. The poll phase sits and waits for the OS to signal that data is ready, then processes the callbacks. The single JavaScript thread is not spinning -- it is genuinely idle, waiting efficiently.

When you await fetch(url) or fs.promises.readFile(path), the operation is handed off to libuv, which hands it off to the OS. The poll phase is where the result comes back.

5. Check

This phase runs callbacks registered with setImmediate. The name "check" reflects that it runs right after the poll phase to let you schedule work that should happen after the current round of I/O but before the next timer fires.

JavaScript
setImmediate(() => console.log('check phase')); setTimeout(() => console.log('timers phase'), 0); // Order is not guaranteed at the top level, but inside an I/O callback, setImmediate always fires first

6. Close Callbacks

This is where close events fire -- socket closures, stream.destroy() callbacks, and similar. Putting cleanup at the end of the loop means any in-flight I/O that arrived late during the poll phase has already been processed before the connection is torn down.

process.nextTick: Between Every Phase

process.nextTick is not a phase. It is a micro-queue that drains between every phase transition.

After the timers phase finishes and before pending callbacks begins, all nextTick callbacks run. After pending callbacks finishes and before idle/prepare begins, they run again. This happens at every boundary.

JavaScript
process.nextTick(() => console.log('runs before the next phase')); setImmediate(() => console.log('runs in the check phase')); Promise.resolve().then(() => console.log('also runs before the next phase'));

Promises use a similar micro-queue. Both process.nextTick and resolved promises drain completely before the event loop moves to the next phase.

The consequence: if you recursively call process.nextTick inside a nextTick callback, you can starve the event loop. The phase transitions never happen because the micro-queue never empties.

The Full Sequence

Plain text
Main Module (synchronous, runs once) | v [ Timers ] --> [ Pending Callbacks ] --> [ Idle/Prepare ] --> [ Poll ] --> [ Check ] --> [ Close Callbacks ] ^ | |___________________________________________________________________________________________| (nextTick queue drains between each phase)

This cycle repeats until there are no more timers registered, no pending I/O, and no setImmediate callbacks. At that point Node terminates -- unless something is keeping the loop alive, like a server listening on a port.

What This Changes

With this map, a lot of Node behavior that seemed mysterious becomes predictable.

Why does setTimeout(fn, 0) not always run before setImmediate? Because at the top level, the timer might not be registered before the check phase is reached.

Why do timer callbacks sometimes fire late? Because the poll phase was blocked processing I/O.

Why does a recursive process.nextTick freeze your server? Because it prevents the event loop from ever advancing to the poll phase where your incoming requests are waiting.

Each phase gets its own deep dive in the posts that follow. The timers phase alone has enough nuance to fill a full post. The poll phase does too. But all of those details land differently once you have seen the full sequence first.

The Essentials

  1. Node.js is V8 plus libuv. V8 runs JavaScript. libuv runs the event loop and handles all I/O. They are separate libraries with separate concerns.
  2. The main module runs before the event loop. Synchronous startup code blocks everything else. Keep it lean.
  3. Timers give you a lower bound, not an exact delay. A busy event loop means late callbacks.
  4. Poll is where I/O happens. This is the phase where your file reads, network responses, and database queries return.
  5. process.nextTick drains between every phase. It is not a phase -- it is a micro-queue that runs at every boundary. Recursive calls will starve the loop.
  6. Promises are callbacks with syntax. async/await does not change the event loop model -- it changes how you write the callbacks.

Further Reading and Watching