Skip to main content

Command Palette

Search for a command to run...

Node.js Event Loop: the interview answer is wrong, here's what actually happens

Updated
5 min read

Week 1 of learning backend with actual depth. This is blog 1 of the series where I document what I'm working on and learning, what I got wrong, and what finally made sense :0


You know the answer. You've said it in interviews. You've read it in a hundred blog posts.

"Node.js is single-threaded and non-blocking. It uses an event loop to handle async operations without blocking the main thread."

Technically not wrong. But also kind of useless. Because the moment an interviewer asks "okay but how does the event loop actually work" or you see output you didn't expect from your own code, that answer falls apart instantly.

I've been there. Most people have. So let's actually fix it.


The answer everyone gives (and why it's incomplete)

Ask any dev what the event loop is and they'll say something like "it checks if the call stack is empty and then pushes callbacks from the queue onto it."

Cool. But which queue? Because there isn't just one.

This is the part nobody talks about. There are multiple queues, they have different priorities, and they run in a specific order every single tick. That order is what actually matters when your async code does something unexpected.


What's actually happening under the hood

Node.js runs on top of libuv, a C library that handles all the async I/O stuff. The event loop itself is a libuv construct, not a JavaScript thing. JS just gets to use it.

The loop runs in phases. Each phase has its own queue of callbacks. The loop goes through them in order, every single tick, and it doesn't move to the next phase until the current one is drained (or hits its limit).

Here are the phases you actually need to know:

Phase 1: Timers

This is where setTimeout and setInterval callbacks live. But here's the thing people get wrong - the delay you pass in is a minimum delay, not a guarantee.

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

This doesn't run immediately. It runs when the event loop gets to the timers phase AND the delay has elapsed. Even with 0ms, other things can jump ahead of it.

Phase 2: Poll

This is the workhorse phase. It handles I/O callbacks - things like reading files, network requests, database queries. The loop spends most of its time here, waiting for new I/O events to come in.

const fs = require('fs');

fs.readFile('file.txt', (err, data) => {
  // this callback lives in the poll phase
  console.log('file read done');
});

Phase 3: Check

This is where setImmediate callbacks run. Always runs after the poll phase, which is why setImmediate often beats setTimeout even when both have the same delay.

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

The part that actually trips people up: microtasks

Here's what most event loop explanations skip entirely.

Before the loop moves from one phase to the next, it drains two special queues first:

  • process.nextTick() callbacks

  • Promise callbacks (.then, async/await continuations)

And process.nextTick runs before promises. Every time. Even if the promise was created first.

This is why this code probably doesn't output what you expect:

console.log('1 - sync');

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

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

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

console.log('5 - sync');

Most people guess: 1 - sync, 5 - sync, 2 - setTimeout, 3 - promimse, 4 - nextTick

Actual output:

1 - sync
5 - sync
4 - nextTick
3 - promise
2 - setTimeout

Walk through it:

  1. Synchronous code runs first, always. So 1 and 5 print immediately.

  2. Before moving to the next event loop phase, microtasks drain. nextTick queue goes first, then promises.

  3. Only after all microtasks are done does the loop move to the timers phase and run the setTimeout callback.


Why this actually matters in real code

This isn't just interview trivia. This stuff bites you in production.

Say you have a function that sometimes returns synchronously and sometimes returns asynchronously depending on a cache hit:

function getData(id, callback) {
  if (cache[id]) {
    callback(cache[id]); // sync
  } else {
    db.fetch(id, callback); // async
  }
}

This is a classic bug pattern called "releasing Zalgo." Your callback fires at different times depending on the code path, and that inconsistency causes race conditions that are genuinely painful to debug.

The fix is to always make callbacks async, even when you have the data:

function getData(id, callback) {
  if (cache[id]) {
    process.nextTick(() => callback(cache[id])); // now consistently async
  } else {
    db.fetch(id, callback);
  }
}

process.nextTick pushes it to the microtask queue so it always fires after the current operation completes, keeping behavior consistent regardless of the code path.


The quick mental model for interviews

Next time someone asks you about the event loop, here's the actual answer:

The event loop runs in phases: timers, poll, check (and a few others). Each phase has its own callback queue. But before moving between phases, Node drains the microtask queues first: process.nextTick goes before promises, and both go before any phase transition.

So the execution order is:

  1. Synchronous code

  2. process.nextTick callbacks

  3. Promise callbacks

  4. Event loop phases (timers, poll, check...)

That's it. That's the answer that actually holds up.