Last updated: 2026-09-14

The Event Loop

The event loop is the mechanism that allows Node.js to handle many things concurrently without using multiple threads. It is the reason a single-threaded runtime can serve thousands of simultaneous connections, and it is the source of the most common mistakes Node.js developers make when they do not understand it fully.

Everything about asynchronous programming in Node.js — callbacks, Promises, async/await — is built on top of the event loop. Understanding what the event loop is and how it operates is not optional background knowledge. It is the mental model you need to reason correctly about the order in which your code runs.


The Problem the Event Loop Solves

JavaScript in Node.js runs on a single thread. There is one call stack, and only one function can execute at any given moment. This raises an obvious question: if Node.js is single-threaded, how does it handle a database query, a file read, and an incoming HTTP request at the same time without one blocking the others?

The answer is that it does not handle them all at the same time — not in the sense of true parallelism. What it does instead is ensure that waiting is never done on the main thread. When an operation requires waiting — for a disk read, a network response, a timer to elapse — Node.js hands that operation to the operating system or to libuv's thread pool and immediately moves on. When the operation completes, a notification arrives and the associated callback is queued for execution.

The event loop is the mechanism that manages this cycle: offloading work, watching for completions, and scheduling callbacks to run when the call stack is clear.


The Components Involved

To understand the event loop, you need a clear picture of the pieces it coordinates.

The Call Stack

The call stack is where JavaScript execution happens. When a function is called, a frame is pushed onto the stack. When it returns, its frame is popped off. Code runs top to bottom, and only one frame executes at a time.

function multiply(a, b) {
  return a * b;
}

function square(n) {
  return multiply(n, n);
}

console.log(square(4)); // 16

During the execution of square(4), the call stack looks like this:

[multiply]   ← executing
[square]     ← called multiply, waiting for it to return
[main]       ← called square, waiting for it to return

The event loop only runs when the call stack is empty. As long as JavaScript is actively executing, the event loop waits.

The Heap

The heap is where objects are stored in memory. It has no bearing on execution order — it is simply the memory space where variables and objects live. The call stack holds references into the heap.

Web APIs / libuv

When Node.js code initiates an asynchronous operation — reading a file, making an HTTP request, setting a timer — that operation is handed off. In the browser, the browser's Web APIs handle it. In Node.js, libuv handles it, either by using the operating system's own async interfaces (for networking) or by executing the operation on a background thread in its thread pool (for file system operations).

The key point: this work happens outside the call stack. The main thread is free to continue running other JavaScript while libuv handles the waiting.

The Callback Queues

When an asynchronous operation completes, its callback is not immediately placed on the call stack. It is placed in a queue. The event loop's job is to take callbacks from these queues and push them onto the call stack when it is empty.

There are multiple queues, and they have a strict priority order.


The Event Loop Phases

The event loop runs in a continuous cycle. Each cycle — called a tick — moves through a fixed sequence of phases. Each phase has its own queue of callbacks to process.

   ┌─────────────────────────────────┐
   │            timers               │  setTimeout, setInterval
   └───────────────┬─────────────────┘
                   │
   ┌───────────────▼─────────────────┐
   │        pending callbacks        │  I/O callbacks from previous tick
   └───────────────┬─────────────────┘
                   │
   ┌───────────────▼─────────────────┐
   │          idle, prepare          │  internal use only
   └───────────────┬─────────────────┘
                   │
   ┌───────────────▼─────────────────┐
   │              poll               │  retrieve new I/O events
   └───────────────┬─────────────────┘
                   │
   ┌───────────────▼─────────────────┐
   │             check               │  setImmediate
   └───────────────┬─────────────────┘
                   │
   ┌───────────────▼─────────────────┐
   │         close callbacks         │  socket.on('close', ...)
   └─────────────────────────────────┘

Timers Phase

Executes callbacks scheduled by setTimeout() and setInterval() whose delay threshold has elapsed. The delay is a minimum threshold, not a guarantee — a callback with a 100ms delay will not run until at least 100ms have passed, but it may run later if a previous phase took longer than expected.

Pending Callbacks Phase

Executes I/O callbacks that were deferred from the previous loop iteration. Certain I/O errors and edge-case callbacks land here rather than in the poll phase.

Idle, Prepare Phase

Used internally by Node.js. Not accessible from application code.

Poll Phase

This is where the event loop spends most of its time. The poll phase does two things:

  1. Processes callbacks in the poll queue — I/O events that have completed (a file has been read, a network packet has arrived)
  2. Calculates how long to block and wait for new I/O events, if the queue is empty

If there are no timers scheduled and no setImmediate callbacks waiting, the event loop will sit in the poll phase and wait for new I/O events to arrive. When an event arrives, it executes its callback immediately.

If timers are scheduled, the event loop will wait in the poll phase only until the earliest timer threshold — then it moves on to the timers phase.

Check Phase

Executes callbacks scheduled with setImmediate(). setImmediate always runs after the poll phase completes, in the same iteration of the event loop. This distinguishes it from setTimeout(() => {}, 0), which schedules a callback for the next timers phase.

Close Callbacks Phase

Executes close event callbacks — for example, socket.on('close', callback) when a socket or handle is closed abruptly.


Microtasks

Separate from the event loop phases are microtasks — a higher-priority queue that runs between every phase transition, not as part of any single phase.

There are two microtask queues, processed in this order:

  1. process.nextTick queue — callbacks registered with process.nextTick()
  2. Promise callbacks — .then(), .catch(), and .finally() handlers, plus await continuations

After every task in any event loop phase completes, Node.js drains both microtask queues completely before moving on to the next phase or the next task in the current phase. This means microtasks always run before the next event loop phase begins.

setTimeout(() => console.log('1 — setTimeout'), 0);

setImmediate(() => console.log('2 — setImmediate'));

Promise.resolve().then(() => console.log('3 — Promise'));

process.nextTick(() => console.log('4 — nextTick'));

console.log('5 — synchronous');

Output:

5 — synchronous
4 — nextTick
3 — Promise
1 — setTimeout
2 — setImmediate

The execution order:

  • 5 runs immediately — it is synchronous code on the call stack
  • 4 runs next — process.nextTick drains before anything else
  • 3 runs next — Promise microtasks drain after nextTick
  • 1 and 2 run after microtasks are clear — but their relative order depends on which phase the event loop enters first

The order of setTimeout vs setImmediate when both are scheduled from the main module (not inside an I/O callback) is non-deterministic — it depends on timing factors outside your control. Inside an I/O callback, setImmediate always runs before setTimeout.


process.nextTick vs setImmediate

Despite its name, process.nextTick does not execute on the next iteration of the event loop. It executes before the event loop moves to its next phase — even before Promise callbacks. setImmediate fires on the next iteration of the event loop, in the check phase.

setImmediate(() => console.log('setImmediate'));
process.nextTick(() => console.log('nextTick'));

// Output:
// nextTick
// setImmediate

The Node.js documentation acknowledges that the naming is confusing and, if the API were being designed today, process.nextTick would likely be called process.beforeNextPhase and setImmediate would be called setNextIteration.

When to Use Each

process.nextTick is appropriate when you need something to run after the current operation completes but before any I/O events are processed — for example, to emit an error after a constructor returns but before the caller has had a chance to attach an error handler:

function MyEmitter() {
  EventEmitter.call(this);
  process.nextTick(() => {
    this.emit('event');
  });
}

setImmediate is appropriate when you want to schedule a callback to run on the next iteration of the event loop, after I/O events have had a chance to be processed.

Be careful with process.nextTick — if callbacks added to the nextTick queue themselves add more nextTick callbacks, the queue grows recursively and the event loop phases after it never run. Node.js will drain the nextTick queue entirely before proceeding, starving all I/O.


Blocking the Event Loop

Because the call stack must be empty for the event loop to run, any synchronous operation that takes a long time will prevent callbacks from executing for that duration. This is called blocking the event loop, and it is the most damaging mistake you can make in a Node.js application.

// This blocks the event loop for the entire duration
function fibonacci(n) {
  if (n <= 1) return n;
  return fibonacci(n - 1) + fibonacci(n - 2);
}

const result = fibonacci(45); // takes several seconds

While fibonacci(45) is running, no timers fire, no I/O callbacks execute, and no incoming HTTP requests are handled. Every concurrent user of the server waits.

What Blocks the Event Loop

OperationBlocks?Notes
fs.readFileSync()YesSynchronous file read — avoid in servers
fs.readFile()NoAsync — hands off to libuv
JSON.parse() on a large payloadYesSynchronous CPU work
A tight for loopYesCPU-bound, no yield point
crypto.pbkdf2Sync()YesExpensive synchronous crypto
crypto.pbkdf2()NoAsync version — uses thread pool
setTimeout(fn, 0)NoSchedules on next timers phase
Database query (via async driver)NoHands off to network I/O

The rule is straightforward: never run slow synchronous code in a Node.js server's request handling path. If CPU-intensive work is necessary, use worker threads, a child process, or offload it to a separate service.


A Complete Execution Example

const fs = require('fs');

console.log('A');

setTimeout(() => console.log('B'), 0);

setImmediate(() => console.log('C'));

fs.readFile(__filename, () => {
  console.log('D');

  setTimeout(() => console.log('E'), 0);
  setImmediate(() => console.log('F'));
  process.nextTick(() => console.log('G'));
});

Promise.resolve().then(() => console.log('H'));

process.nextTick(() => console.log('I'));

console.log('J');

Output:

A
J
I
H
B  ← or C, order of B and C is non-deterministic from the main module
C
D
G
F
E

Tracing through:

  1. A and J are synchronous — they run immediately on the call stack
  2. I runs — process.nextTick drains before any event loop phase
  3. H runs — Promise microtasks drain after nextTick
  4. B and C run in non-deterministic order — scheduled from the main module before an I/O phase
  5. The fs.readFile callback runs — file read complete, poll phase picks it up
  6. G runs immediately after D — process.nextTick inside an I/O callback drains before the next phase
  7. F runs — setImmediate inside an I/O callback always runs before setTimeout from the same context
  8. E runs — setTimeout in the timers phase of the next iteration

Why This Matters in Practice

Most of the time, the precise ordering of the event loop phases is not something you need to consciously manage. You write async code, you handle callbacks or Promises, and things work correctly.

The event loop becomes relevant in specific situations:

Performance under load. A Node.js server that handles requests slowly is often blocking the event loop somewhere. Identifying synchronous bottlenecks requires knowing how execution flows through the event loop.

Ordering bugs. When callbacks appear to run in the wrong order, or when a value is not yet set when a function reads it, the cause is almost always a misunderstanding of how the event loop schedules execution.

Recursive async patterns. Code that generates new callbacks from inside callbacks — polling loops, retry logic, recursive data fetching — can starve the event loop if written carelessly with process.nextTick or block it if written synchronously.

Testing async code. Writing reliable tests for asynchronous code requires understanding when callbacks will run relative to each other, which means understanding the event loop.


Summary

ConceptWhat It Means
Event loopThe continuous cycle that checks for completed async operations and runs their callbacks
Call stackWhere JavaScript executes — one function at a time
TickOne complete cycle through all event loop phases
Timers phaseRuns setTimeout and setInterval callbacks whose delay has elapsed
Poll phaseProcesses completed I/O callbacks; waits for new I/O events if idle
Check phaseRuns setImmediate callbacks
Microtasksprocess.nextTick and Promise callbacks — run between every phase transition
process.nextTickRuns after current operation, before next event loop phase — before Promise callbacks
setImmediateRuns in the check phase of the next event loop iteration
Blocking the event loopRunning slow synchronous code that prevents callbacks from executing