Node.js Internals

Node.js Event Loop

Node runs your JavaScript on a single thread, yet handles thousands of connections. The event loop and its phases are why that works — and why blocking it hurts.

intermediate15 min readUpdated Sep 15, 2026
order.js
js
// order.js
console.log("1: synchronous");

setTimeout(() => console.log("5: timers phase"), 0);
setImmediate(() => console.log("6: check phase"));

Promise.resolve().then(() => console.log("3: microtask"));

process.nextTick(() => console.log("2: nextTick"));
Threads
One main thread
Engine
V8 plus libuv
Phases
Timers, poll, check, close
Microtasks
nextTick and promises
Thread pool
Default size 4
Blocking
Stalls everything

Why it matters

Why the event loop matters

One thread, many connections

The loop multiplexes thousands of sockets on a single thread by never waiting synchronously.

Ordering is predictable

Knowing the phases and queues lets you reason about when callbacks run.

Blocking is the enemy

Any long synchronous task freezes every other request, which is why heavy work moves off the loop.

The big picture

The three parts of the loop

Phases decide what runs when, microtasks run between phases, and the thread pool handles work that cannot be non-blocking.

Phases

Schedule

The loop cycles through timers, pending callbacks, poll, check and close callbacks.

Queues

Prioritise

Microtasks drain between phases, and nextTick runs before promise callbacks.

Thread pool

Offload

File, DNS and crypto work runs on libuv's thread pool so the loop stays free.

The event loop at a glance

The core ideas

Timers phase

Runs callbacks scheduled by setTimeout and setInterval that are due.

Poll phase

Retrieves new I/O events and executes their callbacks, blocking when idle.

Check phase

Runs setImmediate callbacks right after the poll phase.

Microtasks

process.nextTick and promise callbacks run between phases.

Thread pool

libuv runs fs, DNS and some crypto on worker threads.

Monitoring

Watch loop delay and event loop utilization to spot blocking.

A short history

From blocking servers to event-driven I/O

  1. 2009

    Event-driven Node

    Node pairs V8 with libuv to run JavaScript with non-blocking I/O.

    09
  2. 2011

    libuv extracted

    The event loop and platform abstractions become a standalone library.

    11
  3. 2015

    Promises and microtasks

    Native promises add a new queue with clear ordering rules.

    15
  4. 2020

    Better diagnostics

    Tools such as event loop utilization make blocking measurable.

    20
  5. Today

    The same model everywhere

    The event loop underpins servers, tools, serverless runtimes and even browsers.

    Today

The complete guide

Node.js Event Loop: Everything you need to know

Why the event loop matters

Node.js runs your JavaScript on a single main thread, yet a single server can handle thousands of simultaneous connections. That is possible because the event loop never waits. When an operation would block, Node hands it to the operating system or to a thread pool and continues with other work. When the result is ready, a callback is queued and the loop runs it.

Understanding the loop explains a lot of Node behaviour: why a synchronous file read freezes the whole server, why a promise logs before a zero-delay timer, and why CPU-heavy work needs a worker. It is the single most important mental model for writing fast Node.

The loop and its phases

The event loop is a cycle of phases. Each phase has a queue of callbacks, and the loop processes them before moving on.

  1. Timers — callbacks scheduled by setTimeout and setInterval that are due.
  2. Pending callbacks — deferred system callbacks, such as some TCP errors.
  3. Idle, prepare — internal bookkeeping.
  4. Poll — retrieves new I/O events and runs their callbacks. If nothing is ready, it may block here briefly.
  5. Check — runs setImmediate callbacks.
  6. Close callbacks — handles events like socket closure.

The loop keeps cycling as long as there is work to do. When there are no timers, no pending I/O and no handles, the process exits.

Microtasks: nextTick and promises

Between phases, and after each callback, Node drains two high-priority queues:

  • process.nextTick callbacks, which run first.
  • Promise callbacks, which run after nextTick.

Both are drained completely before the loop advances, which has two consequences. First, they run before timers and I/O callbacks scheduled earlier. Second, a recursive nextTick or an endless promise chain can starve the loop, preventing I/O from ever running.

// order.js
console.log("1: synchronous");

setTimeout(() => console.log("5: timers"), 0);
setImmediate(() => console.log("6: check"));

Promise.resolve().then(() => console.log("3: promise"));
process.nextTick(() => console.log("2: nextTick"));

The output is 1, 2, 3, then 5 and 6. The order of 5 and 6 can vary at the top level because it depends on how long it takes to reach the timers phase; inside an I/O callback, setImmediate always runs before setTimeout.

The libuv thread pool

Some operations cannot be done asynchronously by the OS in a portable way: file system access, DNS lookups and some cryptographic functions. libuv runs these on a thread pool, defaulting to four threads, configurable with UV_THREADPOOL_SIZE.

That is why fs.readFile is non-blocking for your code even though it is not event-driven underneath. It also means a burst of file operations can queue behind a pool of four. The async APIs keep the main thread free; the pool handles the waiting.

Blocking the event loop

Anything synchronous and slow on the main thread blocks everything: other requests, timers and I/O. Common culprits include:

  • readFileSync and other synchronous file calls in request handlers.
  • Large JSON.parse or JSON.stringify on big payloads.
  • Synchronous cryptography, such as pbkdf2Sync with high iterations.
  • Tight loops, regex backtracking and heavy data transforms.
  • Blocking libraries that do CPU work on the main thread.

Measure rather than guess: event loop delay and event loop utilization tell you how much time the loop spends blocked. A rising delay under load is the signal.

Moving work off the loop

When work is genuinely CPU-bound, move it:

  • worker_threads for computation in the same process, with message passing.
  • child processes for heavier or isolated jobs.
  • A separate service for the heaviest workloads, scaled independently.
  • Chunking: break large tasks into smaller pieces and yield between them.

For I/O, use the async APIs and streams so the loop stays free.

Best practices

  • Use async APIs in request handlers; never block the loop.
  • Yield to I/O with setImmediate rather than recursive nextTick.
  • Move CPU-bound work to worker threads or child processes.
  • Stream large data instead of buffering it all in memory.
  • Tune UV_THREADPOOL_SIZE only after measuring.
  • Monitor event loop delay and utilization in production.
  • Keep callback chains short so microtasks do not starve I/O.

Common mistakes

  • Using readFileSync inside a hot path.
  • Recursive process.nextTick that starves the loop.
  • Assuming Node is fully single-threaded and ignoring the thread pool.
  • Doing heavy computation inline instead of offloading it.
  • Expecting setTimeout(fn, 0) to run before I/O callbacks.
  • Ignoring event loop delay until latency problems appear.

Where to go next

The event loop is the heart of Node. Continue with Streams for chunked I/O and File System for async file access. Revisit Node.js Basics if the runtime is still new, and then measure event loop delay on a service you run.

Reading a file

Async work runs on the thread pool and frees the loop. A synchronous call blocks every other request while the disk responds.

Prefer
import { readFile } from "node:fs/promises";

const data = await readFile("big.log", "utf8");
res.end(data);
Avoid
import { readFileSync } from "node:fs";

// blocks the entire server
const data = readFileSync("big.log", "utf8");
res.end(data);

Scheduling work

nextTick runs before promise callbacks and before the loop continues. setImmediate runs in the check phase after I/O.

Prefer
// after current I/O, don't starve
setImmediate(() => processBatch());
Avoid
// recursive nextTick starves
// the loop and blocks I/O
process.nextTick(() => loop());

FAQ

Frequently asked questions

Keep learning

Related topics from the roadmap.

$ start learning

Ready to start learning Event Loop?

Our interactive tutorial walks you through Event Loop step by step — with quizzes and real code you can run in the browser.