Introduction

The Node.js event loop is the beating heart of every Node application. It powers non-blocking I/O, orchestrates timers, resolves promises, schedules callbacks, and coordinates the thread pool. Understanding it deeply is the difference between apps that feel crisp and resilient under load, and apps that stall, leak resources, or starve I/O.

This tutorial takes you from beginner-friendly mental models to advanced, production-grade techniques. You’ll learn what the event loop is, how it’s implemented (libuv), the phases and microtask semantics, how timers work, how to measure and improve event loop health, and how to avoid common pitfalls like starvation and blocking. By the end, you’ll be comfortable reasoning about execution order, building reliable async flows, and tuning performance with confidence.

Note: This article targets modern Node.js (v16+), and all examples work in current LTS (e.g., Node 18–22). Where version-specific behavior matters, it’s called out explicitly.

Table of Contents

What Is the Event Loop?

The event loop is a loop that runs on the main thread of a Node.js process. It continuously checks for work and processes it in stages (“phases”), including timers, pending callbacks, I/O events, check callbacks (setImmediate), and close callbacks. Microtasks (Promises and process.nextTick) are drained at specific checkpoints between and after callbacks.

Conceptually:

  • You run some JS at startup.
  • The event loop takes over, repeatedly picking the next “things to do”.
  • As callbacks fire, your JS runs again—potentially queuing more work.

Node implements the event loop with the C library libuv, which also provides a small thread pool for filesystem, DNS, crypto, and compression.

Key Concepts and Terminology

  • Tick: One full iteration through the event loop’s phases.
  • Macro task (task): Work scheduled by timers, I/O, setImmediate, etc. Processed in the libuv phases.
  • Microtask: Higher-priority jobs (Promises/queueMicrotask and process.nextTick) executed between tasks and after callbacks.
  • Phase: A step in the event loop tick (timers, poll, check, etc.).
  • Thread pool: A fixed-size pool (default 4) for offloading blocking operations (FS, crypto, etc.).

A High-Level Tour of a “Tick”

At a high level, one iteration (tick) of Node’s event loop includes:

  1. Timers: Run callbacks for setTimeout/setInterval whose time has elapsed.
  2. Pending Callbacks: Run some system-level callbacks deferred to the next loop.
  3. Idle/Prepare: Internal libuv bookkeeping.
  4. Poll: Receive new I/O events; run their callbacks. If no timers are due, poll may block waiting for I/O.
  5. Check: Run setImmediate callbacks.
  6. Close Callbacks: Run close event listeners (e.g., socket.on(‘close’, …)).

Microtasks are drained at well-defined checkpoints:

  • After each callback completes, Node first empties the process.nextTick queue, then the Promise microtask queue (queueMicrotask, resolved Promises).
  • At the end of each phase, Node drains microtasks again.

Note: process.nextTick has higher priority than Promise microtasks. Abusing nextTick can starve the event loop.

libuv Phases: The Real Execution Order

From libuv’s perspective, the order is:

  1. timers
  2. pending callbacks
  3. idle
  4. prepare
  5. poll
  6. check
  7. close callbacks

Key points:

  • setTimeout/setInterval callbacks run in “timers”.
  • I/O callbacks run in “poll”.
  • setImmediate callbacks run in “check”.
  • “pending callbacks” and “close callbacks” are used for specific OS events and cleanup.
  • Microtasks are Node’s runtime feature and not a libuv phase; Node injects microtask checkpoints between callbacks and phases.

Microtasks in Node.js: process.nextTick vs Promises

Two distinct microtask queues:

  • process.nextTick queue: Highest priority. Drained immediately after the currently executing JS frame and after each callback, before any other queued work.
  • Promise microtask queue: queueMicrotask and then/catch/finally callbacks. Drained after nextTick, at the same checkpoints.

Example:

console.log('start');

process.nextTick(() => console.log('nextTick 1'));
Promise.resolve().then(() => console.log('promise 1'));
queueMicrotask(() => console.log('microtask 2'));

setTimeout(() => console.log('timeout 0'), 0);
setImmediate(() => console.log('immediate'));

console.log('end');

Typical order:

  • start
  • end
  • nextTick 1
  • promise 1
  • microtask 2
  • timeout 0 (timers phase)
  • immediate (check phase)

Warning: A loop of process.nextTick scheduling more nextTicks can starve all other work (timers, I/O, Promises). Prefer Promise microtasks or setImmediate to yield.

Timers: setTimeout, setInterval, and setImmediate

  • setTimeout(fn, ms): Runs fn after at least ms milliseconds have elapsed. Actual timing is not exact; the callback waits until the timers phase runs again and the loop isn’t busy.
  • setInterval(fn, ms): Runs fn repeatedly every ~ms ms. Susceptible to drift if work takes longer than the interval.
  • setImmediate(fn): Runs fn in the “check” phase, after poll. Often fires before a setTimeout(0) when scheduled from within an I/O callback.

Subtlety: setImmediate vs setTimeout(0)

  • From top-level code, you’ll typically see setTimeout(0) fire before setImmediate, because the timers phase precedes check.
  • From inside an I/O callback, setImmediate usually fires before setTimeout(0), because after poll the loop moves directly to check.
import fs from 'node:fs';

fs.readFile(__filename, () => {
  setTimeout(() => console.log('timeout 0 in I/O'), 0);
  setImmediate(() => console.log('immediate in I/O'));
});

// From top level
setTimeout(() => console.log('timeout 0 top-level'), 0);
setImmediate(() => console.log('immediate top-level'));

Typical output:

  • timeout 0 top-level
  • immediate top-level
  • immediate in I/O
  • timeout 0 in I/O

Note: Timer clamping and OS scheduling mean exact ordering isn’t guaranteed across platforms. Rely on phase semantics, not implicit ordering.

Intervals and drift:

  • setInterval can accumulate drift if the callback takes longer than the interval.
  • Prefer a self-correcting schedule:
const interval = 100; // ms
let next = Date.now() + interval;

function tick() {
  // work...
  next += interval;
  setTimeout(tick, Math.max(0, next - Date.now()));
}

setTimeout(tick, interval);

Modern promise-based timers:

import { setTimeout as delay, setImmediate as immediate } from 'node:timers/promises';

await delay(0);
await immediate(); // Check phase

I/O and the Poll Phase: Where Throughput Lives

The poll phase is where libuv waits for I/O (sockets, pipes, etc.) and runs their callbacks. A healthy Node app spends a lot of time here, letting the OS deliver events.

Behavior highlights:

  • If there are pending timers due, poll won’t block for long.
  • If there are setImmediate callbacks queued, poll will complete and the loop will proceed to check.
  • If neither timers are due nor immediate callbacks exist, poll may block awaiting new I/O (efficient idle).

This is why yielding to the event loop (setImmediate, small timeouts, or just returning control) promotes throughput: it lets the loop reach poll and service I/O.

The Thread Pool: FS, DNS, Crypto, and More

libuv ships a small thread pool (default size 4; configurable with UV_THREADPOOL_SIZE) used by:

  • File system operations (fs.readFile, fs.stat, etc.)
  • Some crypto operations (PBKDF2, scrypt, randomBytes)
  • zlib compression
  • dns.lookup (OS resolver via getaddrinfo)
  • Some other legacy async APIs

Notably:

  • dns.resolve uses c-ares (non-thread pool DNS protocol library).
  • Network I/O (sockets) is evented, not thread-pooled.

Increase the pool when you have many concurrent FS/crypto tasks:

UV_THREADPOOL_SIZE=32 node server.js

Warning: Bigger pools aren’t always better. They add context-switching overhead and resource pressure. Measure before and after.

Blocking vs Non-Blocking: Yielding and Offloading

Blocking the main thread prevents the event loop from processing I/O and timers.

Common blocking culprits:

  • CPU-heavy loops (sync crypto, parsing, compression)
  • Synchronous FS (fs.readFileSync, fs.statSync)
  • Large JSON.stringify/parse on big objects
  • Busy-waiting or long synchronous regex

Strategies:

  • Yield to the loop if you can split work:
function burn(ms) {
  const end = Date.now() + ms;
  while (Date.now() < end) {}
}

async function doWorkInChunks(totalMs, chunkMs = 10) {
  const start = Date.now();
  while (Date.now() - start < totalMs) {
    burn(chunkMs);          // do a small chunk
    await new Promise(setImmediate); // yield to check phase
  }
}
  • Offload to Worker Threads for CPU-bound tasks:
// worker.js
import { parentPort } from 'node:worker_threads';
import { scryptSync } from 'node:crypto';

parentPort.on('message', (data) => {
  const out = scryptSync(data.password, data.salt, 64);
  parentPort.postMessage(out.toString('hex'));
});
// main.js
import { Worker } from 'node:worker_threads';
function hash(password, salt) {
  return new Promise((resolve, reject) => {
    const w = new Worker(new URL('./worker.js', import.meta.url), { type: 'module' });
    w.on('message', resolve);
    w.on('error', reject);
    w.postMessage({ password, salt });
  });
}
  • Prefer async APIs that use the thread pool or non-blocking I/O.
  • For timeouts and cancellation, use AbortController with promises:
import { setTimeout as delay } from 'node:timers/promises';

async function withTimeout(promise, ms) {
  const ac = new AbortController();
  const timeout = delay(ms, undefined, { signal: ac.signal })
    .then(() => { throw new Error('Timeout'); });

  try {
    const result = await Promise.race([promise, timeout]);
    ac.abort(); // cancel the timer
    return result;
  } finally {
    // cleanup if needed
  }
}

Measuring Event Loop Health (perf_hooks)

Two crucial metrics are built into Node’s perf_hooks.

  1. Event Loop Delay
import { monitorEventLoopDelay } from 'node:perf_hooks';

const h = monitorEventLoopDelay({ resolution: 10 });
h.enable();

setInterval(() => {
  console.log({
    meanMs: (h.mean / 1e6).toFixed(2),
    p95Ms: (h.percentile(95) / 1e6).toFixed(2),
    maxMs: (h.max / 1e6).toFixed(2),
  });
  h.reset();
}, 1000);
  • mean, percentiles, and max show how long callbacks were delayed due to main-thread contention.
  1. Event Loop Utilization (ELU)
import { eventLoopUtilization } from 'node:perf_hooks';

let prev = eventLoopUtilization();
setInterval(() => {
  const cur = eventLoopUtilization();
  const diff = eventLoopUtilization(prev, cur);
  prev = cur;
  console.log(`ELU: ${(diff.utilization * 100).toFixed(1)}%`);
}, 1000);
  • ELU close to 100% means the loop is busy and not idle in poll. Combine with ELD metrics to identify blocking vs throughput.

Tip: Use these metrics in production dashboards and alerts. Spikes often correlate with synchronous hotspots or thread pool saturation.

Advanced Patterns and Gotchas

  • Starvation via nextTick:
function bad() {
  process.nextTick(bad);
}
bad(); // Timers and I/O will never run

Fix by yielding with setImmediate or using Promise microtasks in moderation:

function better() {
  Promise.resolve().then(better);
}
better(); // Still risky if infinite, but less likely to starve I/O entirely
  • setImmediate to break deep recursion: When implementing streams or flush loops, prefer setImmediate (or await timers/promises setImmediate) to let I/O progress and avoid stack overflows.

  • Microtask “storms”: Continuously enqueueing Promises can still delay timers and I/O because microtasks run after every callback. Throttle or batch them.

  • setInterval cleanup: Always clearInterval; leaking intervals prevents process exit and accrues callbacks.

  • Timers with long durations: Very long timers can be clamped and are not robust for scheduling far in the future. Store deadlines and compute relative delays each iteration.

  • FS concurrency vs thread pool: Massive parallel fs.readFile calls with default thread pool (4) will queue. Consider increasing UV_THREADPOOL_SIZE or using streaming APIs.

  • DNS differences: dns.lookup uses the thread pool and system resolver (respects /etc/hosts). dns.resolve queries DNS servers directly (c-ares). Choose the API that matches your needs.

Browser vs Node Event Loop: Key Differences

  • Node has process.nextTick, which runs before Promise microtasks and can starve the loop if abused. Browsers do not have nextTick.
  • setImmediate is Node-specific (and in some Microsoft environments); browsers don’t have it (postMessage tricks are used instead).
  • Node’s event loop phases come from libuv and include the check phase; browsers do not expose this level of phasing.
  • In modern Node, microtasks are drained after each callback and between phases; browsers generally drain microtasks at the end of each task. The practical upshot: ordering is similar but not identical.

Workers, Cluster, and Multiple Event Loops

  • Worker Threads: Each Worker has its own event loop and JS heap. This is the recommended way to parallelize CPU-bound work within a single process.
import { Worker } from 'node:worker_threads';
const w = new Worker(new URL('./worker.js', import.meta.url), { type: 'module' });
w.postMessage({ job: 'heavy' });
  • Cluster (pre-fork multiple Node processes): Each worker process has its own event loop. Good for scaling I/O-bound servers across CPU cores.
  • Child processes: Separate OS processes with their own loops and memory. Good isolation at IPC cost.

Rule of thumb: For CPU tasks within one app, prefer Worker Threads. For scaling I/O-bound HTTP servers, prefer Cluster or a process manager like PM2 with multiple instances.

Debugging Event Loop Issues

  • Identify blocking code:
    • CPU profiles via node –inspect and Chrome DevTools.
    • clinic.js (Doctor/Flame/Bubbleprof) for production-friendly analysis.
  • Check event loop delay/utilization with perf_hooks.
  • Detect unhandled Promise rejections and synchronous exceptions early.
  • Use async_hooks for tracing async resource lifecycles (advanced, be mindful of overhead).

Quick CPU profile:

node --inspect-brk server.js
# Open chrome://inspect, start a CPU profile during the problematic load

Hands-On: Ordering Experiments You Can Run

Experiment 1: Top-level scheduling

import fs from 'node:fs';

console.log('A: start');

process.nextTick(() => console.log('B: nextTick'));
Promise.resolve().then(() => console.log('C: promise'));

setTimeout(() => console.log('D: timeout 0 (top)'), 0);
setImmediate(() => console.log('E: immediate (top)'));

fs.readFile(__filename, () => {
  console.log('F: I/O callback');

  process.nextTick(() => console.log('G: nextTick in I/O'));
  Promise.resolve().then(() => console.log('H: promise in I/O'));

  setTimeout(() => console.log('I: timeout 0 (in I/O)'), 0);
  setImmediate(() => console.log('J: immediate (in I/O)'));
});

console.log('K: end');

Typical order:

  • A: start
  • K: end
  • B: nextTick
  • C: promise
  • D: timeout 0 (top)
  • E: immediate (top)
  • F: I/O callback
  • G: nextTick in I/O
  • H: promise in I/O
  • J: immediate (in I/O)
  • I: timeout 0 (in I/O)

Experiment 2: Starvation

let count = 0;
function storm() {
  process.nextTick(() => {
    if (++count % 100000 === 0) console.log('still going', count);
    storm();
  });
}
storm();

setTimeout(() => console.log('this may never run'), 0);

Observe how the timer is starved. Replace process.nextTick with Promise.resolve().then to see improved behavior, then insert await new Promise(setImmediate) to yield.

Experiment 3: Measuring ELD and ELU under load

  • Start monitorEventLoopDelay and ELU as shown earlier.
  • Hammer your server with a load test.
  • Introduce a synchronous hotspot (e.g., JSON.stringify on a big object) and watch metrics spike.

Conclusion

Mastering the Node.js event loop means mastering Node itself. From the libuv phases to microtask semantics, from timers and I/O to the thread pool, you now have a complete mental model for how work flows through a Node process. With that model, you can:

  • Predict and verify execution order.
  • Avoid starvation and blocking by yielding or offloading work.
  • Monitor and tune event loop health in production.
  • Choose the right primitives (setImmediate, timers, Promises, Worker Threads) for robust, high-throughput systems.

Keep these principles in mind, measure continuously, and your Node apps will remain responsive under pressure.

Resources

Tip: Re-run the ordering experiments whenever you upgrade Node across major versions and platforms. While the general semantics are stable, timer clamping and OS differences can subtly affect observed ordering.