DeepFrontend
Practice ArenaBlogSign in
Go Pro
DeepFrontend

Interview-grade learning paths and hands-on practice for mid-to-senior engineers. Master internals, patterns, and system architecture with runnable code.

All systems operational

Core Courses

  • -JavaScript Internals
  • -React Reconciliation
  • -TypeScript rigor
  • -Next.js Caching
  • -Node.js Event Loop
  • -System Design
  • -Web Security
  • -CSS & Page Layouts

Explore

  • Learning Paths
  • Practice Arena
  • Pricing Options
  • HTML Sitemap

Resources

  • Privacy Policy
  • Terms of Service
  • Email Support

© 2026 DeepFrontend. All rights reserved.

Expert learning environments for web engineering teams.

Home/Node.js/Event Loop & libuv
intermediate25 min read·Updated Jun 2025

Event Loop & libuv

The browser's event loop (macrotasks vs. microtasks) is only half the story in Node.js — libuv adds six distinct phases, and process.nextTick sits in its own queue with HIGHER priority than promises, draining completely between every single phase transition. This flagship topic walks all of it step by step.

event-looplibuvprocess-nextticksetimmediateconcurrency

Knowledge Check

20 questions · pass at 70%

0/20
easymcq

1. How many distinct phases does libuv's event loop cycle through per iteration?


Interview Questions

2 questions

Cheatsheet

Download-ready reference
Cheatsheet
libuv's SIX phases per iteration (in order):
  1. timers           → setTimeout/setInterval callbacks whose time elapsed
  2. pending callbacks → deferred I/O callbacks from prior iteration (rare)
  3. poll              → new I/O events + their callbacks; CAN BLOCK here
  4. check             → setImmediate callbacks (right after poll)
  5. close callbacks   → e.g. socket.on('close', ...)
  6. → back to timers

PRIORITY (strict): process.nextTick > Promise microtasks > libuv phases
  Both nextTick AND microtask queues drain FULLY between EVERY
  phase transition — not just once per full loop iteration.

process.nextTick: drains to complete emptiness, INCLUDING callbacks
  added to it during its own draining.
  ⚠️ Risk: recursive nextTick can starve the ENTIRE loop (I/O/timers
  never run) — prefer setImmediate for "after I/O, don't starve" needs.

setImmediate vs setTimeout(fn, 0):
  Inside an I/O callback (already in poll) → setImmediate ALWAYS wins
    (check is the very next phase, same iteration)
  At the TOP LEVEL → NOT guaranteed (depends on startup timing)

Long sync work in ANY phase's callback still blocks the WHOLE loop —
  phases organize timing, they don't parallelize your JS.

Related topics

Node.js Runtime OverviewEvent LoopProEventEmitter & Async PatternsPro

On this page

25m
Knowledge CheckInterview QuestionsCheatsheet

Concept#

The beginner framing: Node.js runs your JavaScript on a single thread, just like a browser — but it also needs to talk to the operating system for things like file I/O, network sockets, and timers, and the way it coordinates all of that is the libuv event loop, a more elaborate mechanism than the browser's.

The precise mental model, building on Event Loop (JavaScript): the browser model of "call stack → microtasks → one macrotask → repeat" is a simplification that applies to browsers. Node.js's actual event loop, powered by libuv, cycles through six distinct phases every iteration, and on top of that has a third queue — process.nextTick — that doesn't fit the microtask/macrotask split at all.

The six libuv phases, in order#

  1. timers — runs callbacks scheduled by setTimeout/setInterval whose time has elapsed
  2. pending callbacks — executes I/O callbacks deferred from the previous iteration (rare, mostly internal)
  3. poll — retrieves new I/O events (file reads, network data); executes their callbacks; the loop can block here waiting for I/O if there's nothing else scheduled
  4. check — runs setImmediate callbacks, specifically designed to run right after poll
  5. close callbacks — runs callbacks like socket.on('close', ...)
  6. back to timers, starting a new iteration
const fs = require("fs");
 
setTimeout(() => console.log("timeout"), 0);
setImmediate(() => console.log("immediate"));
 
fs.readFile(__filename, () => {
  // we're now INSIDE the poll phase
  setTimeout(() => console.log("timeout from poll"

Explore this step by step:

libuv's six phases across one full iteration
step 1 / 7
const fs = require('fs');
setTimeout(() => console.log('timer'), 0);
setImmediate(() => console.log('immediate'));
fs.readFile(__filename, () => {
console.log('poll: I/O callback');
setTimeout(() => console.log('timer (from I/O)'), 0);
setImmediate(() => console.log('immediate (from I/O)'));
});
libuv phases (one iteration)
timers
() => console.log('timer')
pending callbacks
poll
fs.readFile callback
check
() => console.log('immediate')
close callbacks
process.nextTick queue
(empty)
Microtask queue
(empty)
Currently executing
(nothing running)
Console output
(nothing logged yet)

The script runs top-to-bottom, synchronously, scheduling four things: a timer (→ timers phase), an immediate (→ check phase), and a file read whose callback (→ poll phase) will itself schedule a NEW timer and a NEW immediate once it runs.

The key insight: a setImmediate scheduled while already inside poll (like inside the fs.readFile callback) runs in check — the very next phase, same iteration. A setTimeout(fn, 0) scheduled at that same moment has to wait for timers in the next iteration, since poll has already passed timers this time around. This is why, specifically inside an I/O callback, setImmediate is guaranteed to fire before a setTimeout(fn, 0) scheduled at the same moment.

process.nextTick: a queue with higher priority than promises#

Node adds one more queue that isn't part of libuv's phases at all: process.nextTick. It drains completely — including any callbacks added to it during its own draining — before the Promise microtask queue gets any turn, and this happens between every single phase transition, not just once per tick.

console.log("start");
 
process.nextTick(() => {
  console.log("nextTick 1");
  process.nextTick(() => console.log("nextTick 3 (added DURING nextTick 2)"));
});
process.nextTick(() => console.log("nextTick 2"));
 
Promise.resolve



process.nextTick drains completely before promises get a turn
step 1 / 5
console.log('start');
setTimeout(() => console.log('timeout'), 0);
Promise.resolve().then(() => console.log('promise'));
process.nextTick(() => console.log('nextTick 1'));
process.nextTick(() => {
console.log('nextTick 2');
process.nextTick(() => console.log('nextTick 3 (added DURING nextTick 2)'));
});
console.log('end');
process.nextTick queue
(empty)
Call stack
console.log('start')
Microtask queue
(empty)
Macrotask / callback queue
(empty)
Console output
start

console.log('start') runs synchronously.

Priority order, strictly: process.nextTick queue > Promise microtask queue > libuv phases (timers → pending callbacks → poll → check → close callbacks).

Non-determinism at the top level#

setTimeout(() => console.log("timeout"), 0);
setImmediate(() => console.log("immediate"));
// which logs first? Depends on process startup timing — NOT guaranteed
Top-level: non-deterministic. Inside an I/O callback: deterministic.
step 1 / 3
// Called from the TOP LEVEL (main module)
setTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate'));
libuv phases (one iteration)
timers
() => console.log('timeout')
pending callbacks
poll
check
() => console.log('immediate')
close callbacks
process.nextTick queue
(empty)
Microtask queue
(empty)
Currently executing
(nothing running)
Console output
⚠️ order NOT guaranteed — depends on process startup timing

At the top level, whether the event loop enters its first TIMERS phase with the 0ms timer already 'due' depends on how much time has actually elapsed since the process started — sometimes it has, sometimes it hasn't. The order between these two is genuinely NOT deterministic here.

At the top level of a script (not inside an I/O callback), whether setImmediate or setTimeout(fn, 0) fires first is not guaranteed — it depends on how long process initialization took before reaching the timers phase for the first time. Inside an I/O callback (already in poll), setImmediate reliably wins, as shown above.


Try It#

Predict the exact output order before checking the solution.

console.log("1");
 
setTimeout(() => console.log("2"), 0);
 
Promise.resolve().then(() => console.log("3"));
 
process.nextTick(() => console.log("4"));
 
console.log
Solution

1, 5, 4, 3, 2.

Synchronous code runs first: 1, then 5. Then, before the event loop can proceed to any phase, the nextTick queue drains completely: 4. Then the Promise microtask queue drains: 3. Only then does the loop enter the timers phase and run the setTimeout callback: 2.


Implement It Yourself#

Build a minimal simulator of the priority ordering (nextTick > microtasks > timers), without needing real timers or promises:

function simulateEventLoop(nextTickQueue, microtaskQueue, timerQueue) {
  const output = [];
 
  function drainNextTick() {
    while (nextTickQueue.length > 0) {
      const cb = nextTickQueue.shift();
      cb(output, nextTickQueue); // callback may push MORE nextTicks — keep draining
    }
  }
 
  function drainMicrotasks



















This mirrors the real priority rule: after any callback runs, nextTick drains fully, then microtasks drain fully, before the loop does anything else — including before moving on to the next timer callback.


Under the Hood#

This topic directly extends Event Loop (JavaScript)'s call-stack/microtask/macrotask model — everything learned there about microtask-vs-macrotask priority still applies; libuv's six phases are simply a more detailed breakdown of what browsers loosely call "macrotasks," and process.nextTick is a Node-specific addition with no browser equivalent. EventEmitter's event-driven callback pattern (see EventEmitter & Async Patterns) is what many I/O completions ultimately surface through once they've been through this loop.


Common Mistakes#

1. Assuming Node's event loop works exactly like the browser's#

// "microtasks always run before the next macrotask" — true in browsers,
// but Node also has process.nextTick, which has an EVEN higher priority
// and drains between EVERY phase, not just once

The browser's two-queue mental model is incomplete for Node — the three-tier priority (nextTick > microtasks > libuv phases) is Node-specific.

2. Assuming setImmediate always beats setTimeout(fn, 0)#

setTimeout(() => console.log("timeout"), 0);
setImmediate(() => console.log("immediate"));
// NOT guaranteed at the top level — only guaranteed inside an I/O callback

This ordering is only deterministic inside an I/O callback (already in the poll phase). At the top level, it depends on startup timing.

3. Recursive process.nextTick calls starving the event loop#

function recurse() {
  process.nextTick(recurse); // ❌ NEVER lets the loop proceed past nextTick draining
}
recurse();

Since nextTick fully drains — including callbacks added during its own draining — before anything else runs, a nextTick callback that keeps re-scheduling itself can starve I/O, timers, and everything else indefinitely.


Best Practices#

  • Use setImmediate for "run this after I/O, but don't starve the loop" — it's designed for exactly that, and won't recursively block other phases the way runaway nextTick recursion can.
  • Reserve process.nextTick for genuinely urgent, short deferrals (like ensuring an error is emitted asynchronously to allow listener attachment first) — not as a general-purpose scheduling tool, given its loop-starvation risk.
  • Don't rely on setImmediate vs. setTimeout(fn, 0) ordering at the top level — if determinism matters, schedule from inside an I/O callback, or use a different explicit ordering mechanism.

Performance Tips#

  • Excessive process.nextTick recursion is a real, production-observed cause of "the event loop appears frozen" bugs — it doesn't block the call stack in the traditional sense, but it does prevent the loop from ever reaching timers/poll/check, so I/O appears to hang indefinitely.
  • Long synchronous work inside any single phase's callback (like the poll phase's I/O callback) still blocks the whole loop for that duration — libuv's phase structure doesn't parallelize your JS callbacks, it only structures when they run relative to I/O and timers.

),
0
);
setImmediate(() => console.log("immediate from poll"));
});
().
then
(()
=>
console.
log
(
"promise"
));
setTimeout(() => console.log("timeout"), 0);
console.log("end");
// start, end, nextTick 1, nextTick 2, nextTick 3, promise, timeout
(
"5"
);
() {
while (microtaskQueue.length > 0) {
const cb = microtaskQueue.shift();
cb(output);
}
}
// Between EVERY phase transition: drain nextTick fully, then microtasks fully.
drainNextTick();
drainMicrotasks();
// Now enter the "timers" phase.
while (timerQueue.length > 0) {
const cb = timerQueue.shift();
cb(output);
drainNextTick(); // still higher priority than anything else
drainMicrotasks();
}
return output;
}