Web Engineering ·

How the Node.js Event Loop Actually Works: Phases, Microtasks, and Production Implications

A deep dive into the six phases of the Node.js event loop, the microtask queue, how I/O interacts with the poll phase, and what all of this means for production TypeScript applications.

How the Node.js Event Loop Actually Works: Phases, Microtasks, and Production Implications

Most Node.js developers have a working mental model of the event loop: callbacks go in, callbacks come out, nothing blocks. That model is sufficient until you hit a surprising execution order in tests, a subtle starvation bug in production, or a support ticket about high latency under load that you cannot reproduce on your laptop.

The event loop is not a queue. It is a structured sequence of six distinct phases, each with its own queue, its own rules, and its own failure modes. Microtasks sit outside those phases and preempt them in ways that catch even experienced engineers off guard. If you have ever wondered why setTimeout(fn, 0) does not behave like setImmediate(fn), or why process.nextTick can freeze your process, the answer is in the phase model.

This article covers the actual mechanics: what happens in each phase, how the microtask queue interacts with phase transitions, how I/O fits into the poll phase, and what this means when you are operating TypeScript services at scale.

The Six Phases

Node.js runs on top of libuv, a C library that handles asynchronous I/O across operating systems. The event loop is implemented inside libuv and advances through six phases on every iteration (commonly called a “tick,” though that word is overloaded and best avoided in technical discussion):

  1. Timers: Executes callbacks scheduled by setTimeout and setInterval whose threshold has passed.
  2. Pending callbacks: Executes I/O callbacks deferred from the previous iteration (TCP errors, for example).
  3. Idle / Prepare: Internal to libuv. Used for housekeeping. You cannot hook into this directly.
  4. Poll: Retrieves new I/O events; executes their callbacks. This is where the loop spends most of its time.
  5. Check: Executes setImmediate callbacks.
  6. Close callbacks: Executes close events (socket.on('close', ...), etc.).

After each phase completes, Node.js drains the microtask queues before moving to the next phase. This is the critical detail that most mental models miss.

The Microtask Queues

There are two microtask queues, and they have a strict priority ordering:

  1. process.nextTick queue: Highest priority. Drained completely before anything else runs.
  2. Promise microtask queue: Drained after the nextTick queue is empty.

Both queues are drained between every phase transition. If a nextTick callback enqueues another nextTick callback, that new callback runs before any Promise .then handlers, and before the next event loop phase begins. This continues recursively until the queue is empty.

Here is a concrete example that surprises most developers on first reading:

import { readFile } from "fs";

console.log("1: start");

setTimeout(() => console.log("2: setTimeout"), 0);

setImmediate(() => console.log("3: setImmediate"));

Promise.resolve().then(() => console.log("4: promise"));

process.nextTick(() => console.log("5: nextTick"));

readFile(__filename, () => console.log("6: readFile callback"));

console.log("7: end");

The output is:

1: start
7: end
5: nextTick
4: promise
2: setTimeout
3: setImmediate
6: readFile callback

Lines 1 and 7 are synchronous, so they run first. Then the microtask queues drain: nextTick before Promise. Only then does the loop advance to the Timers phase (setTimeout), followed by the Check phase (setImmediate). The readFile callback arrives via the Poll phase, which runs after Check in subsequent loop iterations once the kernel reports the I/O ready.

One thing worth noting about setTimeout(fn, 0) versus setImmediate: when called inside the main module (not inside a running I/O callback), their order is non-deterministic because timer precision depends on OS scheduling. Inside an I/O callback, setImmediate always fires before any setTimeout, because the loop is already past the Timers phase when the I/O callback runs.

The Poll Phase in Detail

The Poll phase is where the loop does most of the real work. Its behavior depends on what is in the queue:

  • If there are I/O callbacks in the poll queue, execute them until the queue is empty or a system-defined limit is reached.
  • If the queue is empty, check whether setImmediate callbacks are waiting. If yes, end the Poll phase and move to Check.
  • If neither condition is met, block and wait for new I/O events, with a timeout calculated from the nearest pending timer threshold.

This blocking behavior is what makes Node.js efficient at I/O: rather than spinning in a busy loop, it yields to the OS (via epoll on Linux, kqueue on macOS, IOCP on Windows) and wakes up when events arrive.

The implication for production is that the Poll phase is where the event loop can be blocked by slow callbacks. If your database query callback runs synchronous CPU-heavy processing before returning, every other pending I/O callback waits. That includes incoming HTTP requests.

import { createServer, IncomingMessage, ServerResponse } from "http";

const server = createServer((req: IncomingMessage, res: ServerResponse) => {
  if (req.url === "/slow") {
    // This blocks the entire event loop for every request
    const start = Date.now();
    while (Date.now() - start < 100) {
      // busy wait simulating CPU work: 100ms of blocking
    }
    res.end("done");
    return;
  }
  res.end("fast");
});

server.listen(3000);

Under load, the /slow handler will cause every concurrent request to queue up. The event loop cannot service any other callback until the synchronous block returns.

Starvation via process.nextTick

Because process.nextTick drains fully before the loop can advance, a recursive nextTick will starve the entire loop:

function recursiveNextTick(count: number): void {
  if (count <= 0) return;
  process.nextTick(() => {
    // This schedules another nextTick before the loop can move on
    recursiveNextTick(count - 1);
  });
}

// Schedule 100,000 nextTick calls before the loop can advance
recursiveNextTick(100_000);

setTimeout(() => {
  // This will not run until all 100,000 nextTick callbacks complete
  console.log("timer fired");
}, 0);

Node.js does not impose a default limit on nextTick recursion depth. You can freeze the event loop indefinitely with a careless implementation. The same risk applies to Promise chains that immediately resolve, since they also defer to the microtask queue, though with slightly lower priority than nextTick.

A real version of this bug appears in recursive event emitters and retry loops written without backoff:

import { EventEmitter } from "events";

const emitter = new EventEmitter();

function watchUntilReady(emitter: EventEmitter): void {
  emitter.once("check", () => {
    if (!isReady()) {
      // Re-schedules synchronously in the same microtask drain
      process.nextTick(() => watchUntilReady(emitter));
    }
  });
  emitter.emit("check");
}

function isReady(): boolean {
  return false; // never ready: infinite starvation
}

watchUntilReady(emitter);

Fix this by using setImmediate for retry loops that should yield between iterations:

function watchUntilReady(emitter: EventEmitter): void {
  if (!isReady()) {
    // setImmediate yields to the Check phase, allowing other work to run
    setImmediate(() => watchUntilReady(emitter));
    return;
  }
  emitter.emit("ready");
}

Promise Microtasks and Long Chains

Promise chains are not free. Each .then handler schedules a new microtask. A long synchronous chain of .then calls will drain completely before the next event loop phase runs:

async function deepChain(depth: number): Promise<void> {
  if (depth <= 0) return;
  await Promise.resolve();
  return deepChain(depth - 1);
}

// This will drain ~10,000 microtasks before the next I/O event fires
deepChain(10_000).then(() => console.log("done"));

setImmediate(() => console.log("setImmediate fired"));

In practice, this matters when you are processing large batches of items with async iteration and not yielding back to the event loop. The fix is periodic yielding:

async function processBatch(items: string[]): Promise<void> {
  for (let i = 0; i < items.length; i++) {
    await processItem(items[i]);

    // Yield every 100 items to keep the event loop responsive
    if (i % 100 === 0) {
      await new Promise<void>((resolve) => setImmediate(resolve));
    }
  }
}

async function processItem(item: string): Promise<void> {
  // Simulate some async work
  return Promise.resolve();
}

Diagnosing Event Loop Lag

Event loop lag is the time between when a callback is scheduled and when it actually executes. In a healthy, lightly loaded process, lag is sub-millisecond. Under load, or with blocking callbacks, it climbs.

The canonical way to measure it:

function measureEventLoopLag(sampleIntervalMs: number): () => number {
  let lastCheck = process.hrtime.bigint();
  let lag = 0n;

  const interval = setInterval(() => {
    const now = process.hrtime.bigint();
    const expected = BigInt(sampleIntervalMs) * 1_000_000n;
    const actual = now - lastCheck;
    lag = actual > expected ? actual - expected : 0n;
    lastCheck = now;
  }, sampleIntervalMs);

  interval.unref(); // Do not keep process alive

  return () => Number(lag) / 1_000_000; // Return lag in milliseconds
}

const getLag = measureEventLoopLag(100);

// Sample lag every 5 seconds and log it
setInterval(() => {
  const lagMs = getLag();
  if (lagMs > 50) {
    console.warn(`High event loop lag: ${lagMs.toFixed(1)}ms`);
  }
}, 5000).unref();

Node.js 18.2+ exposes performance.eventLoopUtilization(), which reports the ratio of time the loop spent active versus idle:

import { performance } from "perf_hooks";

const elu1 = performance.eventLoopUtilization();

setTimeout(() => {
  const elu2 = performance.eventLoopUtilization(elu1);
  console.log(`Event loop utilization: ${(elu2.utilization * 100).toFixed(1)}%`);
}, 1000);

Utilization above 80% sustained is a warning sign. It means the loop has little idle time left to absorb load spikes.

For production use, you want to expose this as a metric rather than just logging it. Here is a minimal approach that exports lag as a Prometheus-style gauge:

import { createServer } from "http";
import { performance } from "perf_hooks";

let currentElu = performance.eventLoopUtilization();

setInterval(() => {
  const next = performance.eventLoopUtilization(currentElu);
  currentElu = performance.eventLoopUtilization();
  // In real usage, push this to your metrics pipeline
  process.emit("metrics:elu", next.utilization);
}, 10_000).unref();

createServer((req, res) => {
  if (req.url === "/metrics") {
    const elu = performance.eventLoopUtilization(currentElu);
    res.end(`event_loop_utilization ${elu.utilization.toFixed(4)}\n`);
    return;
  }
  res.end("ok");
}).listen(9090);

The Worker Threads Escape Hatch

When you genuinely need CPU-heavy computation in a Node.js service, the event loop is the wrong place for it. Worker threads give you real OS threads without forking a new process:

import { Worker, isMainThread, parentPort, workerData } from "worker_threads";

// In the main thread
function computeInWorker(input: number[]): Promise<number> {
  return new Promise((resolve, reject) => {
    const worker = new Worker(__filename, {
      workerData: { input },
    });
    worker.on("message", resolve);
    worker.on("error", reject);
    worker.on("exit", (code) => {
      if (code !== 0) reject(new Error(`Worker exited with code ${code}`));
    });
  });
}

// In the worker thread
if (!isMainThread) {
  const { input } = workerData as { input: number[] };
  const result = input.reduce((sum, n) => sum + n, 0); // CPU work
  parentPort?.postMessage(result);
}

// Usage: the main event loop is free while computation runs
if (isMainThread) {
  computeInWorker([1, 2, 3, 4, 5]).then((sum) => console.log("Sum:", sum));
}

Worker threads each have their own event loop, so they do not block the main loop. Communication happens via message passing, with structured cloning of data (or transferable objects like SharedArrayBuffer for zero-copy scenarios).

Production Considerations

Avoid synchronous operations in hot paths. Functions like JSON.parse on large payloads, crypto.pbkdf2Sync, and fs.readFileSync block the event loop for every concurrent request waiting behind them. Profile with --prof or 0x before assuming a bottleneck is I/O.

Set a nextTick budget in retry logic. Any loop that reschedules itself using process.nextTick needs a termination condition that is evaluated synchronously, not after yielding. If the condition depends on I/O, use setImmediate instead.

Track setImmediate queue depth in long-running workers. If you are processing jobs with setImmediate-based iteration and the queue fills faster than it drains, you will accumulate unbounded memory. Add a counter and shed load when depth exceeds a threshold.

Tune UV_THREADPOOL_SIZE for I/O-heavy services. libuv uses a thread pool (default size 4) for disk I/O and DNS lookups. Under high concurrency, all four threads can be busy, and new I/O requests wait in the pool queue. Set UV_THREADPOOL_SIZE to match your expected concurrency, up to 128.

Use perf_hooks ELU in health checks. An elevated utilization reading is an early signal before latency climbs. Return 503 from your health endpoint when ELU exceeds your threshold, so load balancers can redirect traffic before the service degrades.

Cluster mode does not help with in-process blocking. Clustering gives you multiple processes, each with their own event loop. It helps with throughput across cores, but it does not fix a single worker whose loop is blocked. You need both: cluster for core utilization and clean event loop practices for responsiveness.

Monitoring Approach Tradeoffs

ApproachOverheadGranularityProduction SuitabilityNotes
Manual lag timer (hrtime)Very lowPer-intervalHighNo dependencies, portable, easy to tune interval
performance.eventLoopUtilization()NegligiblePer-call deltaHighBuilt-in since Node 18.2; ratio metric rather than absolute lag
node:perf_hooks observer (gc, mark)LowPer eventHighUseful for GC pause correlation with loop lag spikes
clinic.js / 0x flame graphsMedium (profiling mode)Very highDev / staging onlyExcellent for diagnosis; not for continuous production monitoring
APM agent (Datadog, New Relic)Low to mediumHighHighTurnkey, correlates with traces; adds vendor dependency and cost
Custom Prometheus gaugeLowPer-scrape intervalHighIntegrates with existing stack; requires instrumentation code
--inspect + Chrome DevToolsHigh (debugger protocol)Very highNever in prodPauses execution; only for local diagnosis

For most production services, a combination of the built-in ELU API plus a Prometheus gauge exported on a metrics endpoint covers the monitoring need with minimal overhead and no external dependencies.

Closing

The event loop is not magic. It is a loop over six phases with two microtask queues that drain between transitions. That structure explains every surprising execution order you have encountered, every starvation bug you have debugged, and every latency spike that did not correlate with database response times. Once the model is clear, diagnosing and preventing these issues becomes straightforward, not a matter of trial and error.

More in Web Engineering

How Rspack Works Internally: Webpack-Compatible Module Graph, Rust Compilation Pipeline, and the Incremental Build Architecture Behind the Fastest Webpack Replacement
Web Engineering ·

How Rspack Works Internally: Webpack-Compatible Module Graph, Rust Compilation Pipeline, and the Incremental Build Architecture Behind the Fastest Webpack Replacement

A deep dive into Rspack's Rust-based architecture, module graph construction, SWC transformation pipeline, webpack compatibility layer, incremental compilation, and what the tradeoffs look like for teams migrating from webpack.

How Turbopack Works Internally: Incremental Computation, Function-Level Caching, and the Rust-Based Bundler Architecture Behind Next.js
Web Engineering ·

How Turbopack Works Internally: Incremental Computation, Function-Level Caching, and the Rust-Based Bundler Architecture Behind Next.js

A deep dive into Turbopack's architecture covering the Turbo engine's incremental computation model, function-level caching, Rust-based module resolution, granular HMR invalidation, SWC integration, persistent caching, and how it compares to webpack, Vite, esbuild, and Rspack.

How gRPC Works: Protocol Buffers, HTTP/2 Multiplexing, and Bidirectional Streaming From Service Definition to Wire Format
Web Engineering ·

How gRPC Works: Protocol Buffers, HTTP/2 Multiplexing, and Bidirectional Streaming From Service Definition to Wire Format

A deep dive into gRPC internals: Protocol Buffer IDL and the buf codegen pipeline, HTTP/2 stream multiplexing and HPACK header compression, all four RPC patterns with TypeScript, channel management, deadline propagation, interceptors, load balancing from pick-first to xDS, the health checking protocol, and production tradeoffs vs REST and GraphQL.

How Node.js Works Internally: The Event Loop, libuv Thread Pool, and Async I/O Architecture From require() to Process Exit
Web Engineering ·

How Node.js Works Internally: The Event Loop, libuv Thread Pool, and Async I/O Architecture From require() to Process Exit

A deep dive into Node.js internals covering V8 JIT compilation, the six-phase libuv event loop, thread pool mechanics, microtask queue priority, async/await desugaring, CommonJS versus ESM module resolution, and production tuning considerations with TypeScript examples.