Skip to the solution
MediumDSAAmazonAsked in 2024, 20227 min read

How does the Node.js event loop work?

2.9k views

The problem

Question presented to candidate: "Node.js is single-threaded, yet it handles thousands of concurrent connections. Walk me through what actually happens between when your code calls an async function and when its callback runs."

What a strong answer should cover:

  • The event loop is a loop over a fixed set of phases, each with its own queue of callbacks: timers (setTimeout/setInterval), pending callbacks, poll (I/O events β€” most callbacks live here), check (setImmediate), and close callbacks.
  • Between every callback β€” not just between phases β€” Node drains two microtask queues: process.nextTick()'s queue first, then the Promise microtask queue. This is why nextTick and Promise.then always run before the next macrotask (a timer, an I/O callback), no matter how short the timer's delay is.
  • πŸ“Œ The distinguishing, verifiable claim: process.nextTick() recursion can starve the event loop entirely β€” since it is drained as a microtask queue, an unbounded chain of nextTick calls never lets the loop advance to the poll phase, so I/O callbacks never fire. setImmediate, being tied to a real phase (check), cannot cause this β€” the loop still cycles through poll on every iteration.
  • Ordering between setTimeout(fn, 0) (timers phase) and setImmediate() (check phase) at the top level of a script is not guaranteed β€” it depends on process startup timing. Inside an I/O callback, the order is guaranteed: setImmediate always fires before setTimeout(fn, 0), because poll transitions to check before looping back to timers.
  • The single thread runs your JavaScript; actual I/O (disk, some DNS, some crypto) is delegated to libuv's thread pool or the OS's native async APIs (epoll/kqueue/IOCP for networking) β€” the event loop itself never blocks waiting for that work; it is notified when it completes.
  • A good answer distinguishes "the event loop" (a scheduling mechanism) from "concurrency" (achieved by never blocking the single thread on I/O, not by running JS in parallel) β€” Node achieves throughput by staying busy between I/O waits, not by using multiple threads for your code.

Clarifying questions expected:

  • "Are we talking about I/O-bound concurrency, or CPU-bound work?" β€” the event loop model only explains the former; CPU-bound work needs Worker Threads.
  • "Do you want the phase list, or the microtask-vs-macrotask distinction specifically?" β€” these are related but different levels of the same answer.

Code / implementation expected: Yes β€” a single script combining setTimeout, setImmediate, process.nextTick, a Promise, and a real I/O callback, with the actual observed output, is the clearest way to prove the ordering rather than describe it.

  • node
  • event-loop
  • async

Try it yourself

Sketch your approach before you read the solution. That is what the interview tests.

Stuck? Get a hint.

A short nudge toward the approach. It will not give the answer away.

Solution

The solution is hidden so you can try first

7 min read Β· 8 sections

Read the code

JavaScriptFull ordering proof: sync code, nextTick, promises, timers, check phase, and inside-I/O-callback ordering
const fs = require("node:fs");

console.log("1: script start");
setTimeout(() => console.log("6: setTimeout(0)"), 0);
setImmediate(() => console.log("7: setImmediate"));
fs.readFile(__filename, () => {
  console.log("A: fs.readFile callback (poll phase)");
  setTimeout(() => console.log("  B: setTimeout(0) inside I/O"), 0);
  setImmediate(() => console.log("  C: setImmediate inside I/O"));
  process.nextTick(() => console.log("  D: nextTick inside I/O"));
  Promise.resolve().then(() => console.log("  E: promise .then inside I/O"));
});
Promise.resolve().then(() => console.log("4: promise .then"));
process.nextTick(() => console.log("3: process.nextTick"));
console.log("2: script end");

// Actual output:
// 1: script start
// 2: script end
// 3: process.nextTick
// 4: promise .then
// 6: setTimeout(0)
// 7: setImmediate
// A: fs.readFile callback (poll phase)
//   D: nextTick inside I/O
//   E: promise .then inside I/O
//   C: setImmediate inside I/O   <- C before B is GUARANTEED inside an I/O callback
//   B: setTimeout(0) inside I/O

// Starvation test: recursive nextTick vs recursive setImmediate, racing an I/O callback
let count = 0;
fs.readFile(__filename, () => console.log("I/O fired after", count, "iterations"));
function recurseNextTick() {
  count++;
  if (count >= 200000) { console.log("capped, ioFired=false β€” nextTick starved the I/O callback entirely"); return; }
  process.nextTick(recurseNextTick);
}
// Swap for setImmediate(recurseNextTick) and the I/O callback fires after only ~6 iterations instead.

Discussion

Other approaches, follow-ups and how it went in real interviews.

Sign in to join the discussion.

No comments yet. Share how you would answer it.

Question 36 of 152 in Node.js

Keep your momentum going.

Mark this question solved to track your progress, then take the next one while it is fresh.

All Node.js questions
Up nextMedium

What is AbortSignal.timeout and how do you cancel async work cleanly?

Continue