Skip to content
Node.js nd async 4 min read

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.

PhaseWhat it processes
timersCallbacks scheduled by setTimeout and setInterval whose threshold has elapsed
pending callbacksCertain system-level callbacks deferred from the previous loop (e.g. some TCP errors)
idle, prepareInternal use only by libuv
pollRetrieves new I/O events; executes I/O callbacks (file reads, network data); may block here waiting for work
checkCallbacks scheduled by setImmediate
closeclose 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_threads or a child process so the loop stays responsive.
  • Prefer setImmediate over setTimeout(fn, 0) when you want to yield after the current I/O — it has clearer, deterministic ordering inside I/O callbacks.
  • Use process.nextTick sparingly; recursively queuing it can starve the loop and prevent I/O and timers from ever running.
  • Remember that Promise .then and await continuations run as microtasks, so they execute before any timer or setImmediate, 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 --prof or the perf_hooks module to detect long-running callbacks that stall the poll phase.
Last updated June 14, 2026
Was this helpful?