🌱BeginnerNode.js Foundations

The Event Loop

How Node.js processes callbacks across six phases to enable non-blocking async code

Overview

The event loop is the mechanism that allows Node.js to perform non-blocking I/O despite running JavaScript on a single thread. Every time your code registers a callback β€” whether from a timer, a file read, or an HTTP request β€” the event loop is what eventually picks that callback up and runs it when the time is right. The loop runs continuously, cycling through a fixed set of phases. Each phase has a FIFO queue of callbacks to execute. The loop processes all callbacks in a phase before moving to the next. Understanding the phases tells you exactly when your code will run relative to other async operations. The six main phases in order are: timers (runs setTimeout and setInterval callbacks), pending callbacks (system-level I/O error callbacks), idle/prepare (internal use), poll (retrieves new I/O events and runs their callbacks), check (runs setImmediate callbacks), and close callbacks (handles closed sockets/handles). Between each phase, Node drains the nextTick queue and the Promise microtask queue β€” so these always fire before the next phase begins. Knowing this model lets you avoid subtle ordering bugs. For example, a common surprise is that process.nextTick fires before a resolved Promise, because the nextTick queue drains before the microtask queue. Another is that setImmediate reliably fires after the current I/O event whereas setTimeout(fn, 0) may fire in the timers phase before or after setImmediate depending on OS scheduling. Inside an I/O callback, however, setImmediate always fires before any setTimeout.

Key Points

  • β€’The event loop has six phases: timers β†’ pending callbacks β†’ idle/prepare β†’ poll β†’ check β†’ close callbacks
  • β€’The poll phase is where Node waits for new I/O events; most of your I/O callbacks run here
  • β€’setImmediate callbacks run in the check phase, immediately after the poll phase
  • β€’process.nextTick and Promise microtasks run between every phase transition, not in a phase
  • β€’process.nextTick drains fully before Promise microtasks; both drain before the next phase starts
  • β€’Blocking the event loop in any phase (synchronous work) freezes all other callbacks

Examples

Event loop phase ordering β€” timers vs setImmediate vs nextTick vs Promises

setImmediate(() => console.log('setImmediate'));
setTimeout(() => console.log('setTimeout 0'), 0);
Promise.resolve().then(() => console.log('Promise microtask'));
process.nextTick(() => console.log('nextTick'));
console.log('sync');

// Output (always):
// sync
// nextTick            ← nextTick queue drains first
// Promise microtask   ← then Promise microtask queue
// setTimeout 0        ← timers phase (or setImmediate first, OS-dependent outside I/O)
// setImmediate        ← check phase

The nextTick and Promise order is deterministic. The setTimeout vs setImmediate order at the top level can vary β€” but inside an I/O callback setImmediate always wins.

Inside an I/O callback, setImmediate always fires before setTimeout

const fs = require('fs');

fs.readFile(__filename, () => {
  // We are now inside the poll phase
  setTimeout(() => console.log('setTimeout'), 0);
  setImmediate(() => console.log('setImmediate'));
});

// Output (guaranteed):
// setImmediate
// setTimeout
// Because after poll finishes, the loop goes to the CHECK phase (setImmediate)
// before looping back to TIMERS (setTimeout).

process.nextTick can starve the event loop if used recursively

// DANGER: this never lets the event loop advance to the next phase
function dangerousRecursion() {
  process.nextTick(dangerousRecursion);
}
// dangerousRecursion(); // uncomment to freeze Node

// Safe pattern: use setImmediate for recursive async work
function safeRecursion(count) {
  if (count <= 0) return;
  setImmediate(() => safeRecursion(count - 1));
}
safeRecursion(1000); // works fine, each iteration yields to event loop

process.nextTick starves because it runs before advancing to the next phase. setImmediate yields after the current phase.

Visualising the poll phase waiting for I/O

const net = require('net');

const server = net.createServer((socket) => {
  socket.on('data', (data) => {
    // This callback runs in the POLL phase when new data arrives
    console.log('received:', data.toString());
    socket.write('echo: ' + data);
  });
});

server.listen(3000);
console.log('Server listening β€” event loop is polling for connections');
// When no data arrives, the loop blocks in the poll phase waiting.
// When data arrives, the OS wakes it up and the callback runs.

Common Mistakes

  • Relying on setTimeout(fn, 0) vs setImmediate ordering at the top level β€” their order is not guaranteed; use setImmediate when you need "after current I/O"
  • Using process.nextTick recursively without a base case, which starves the event loop indefinitely
  • Assuming Promise.resolve().then() fires before process.nextTick β€” it is actually the other way around
  • Forgetting that synchronous code inside any callback (including async functions between awaits) still blocks the event loop for its full duration

Note

A practical rule: use process.nextTick to defer something until after the current function returns but before any I/O. Use setImmediate to defer something until after the current I/O events. Use setTimeout(fn, 0) only when you specifically need timer semantics.

Exercises

Write Node.js code in your local terminal. Use the editor below to follow along, then mark each exercise as done once it works.

Loading editor…

Completed: 0 / 6 exercises

Knowledge Check

Question 1 of 4

In what order do the following run after synchronous code finishes: setTimeout(fn,0), setImmediate(fn), process.nextTick(fn), Promise.resolve().then(fn)?