> cs·fundamentals
interview 0% 30m read
4.1 ★ core [J][I] 16 interview Q's

The event loop & async model

Single-threaded JS + libuv thread pool, the event-loop phases in order, microtasks vs macrotasks, and the nextTick/setImmediate/setTimeout ordering rules.

Node runs your JavaScript on a single thread, yet it serves thousands of concurrent connections. The trick is that it never waits — slow work is handed off, and a loop keeps pulling completed callbacks back onto the one thread. Understand that loop and the order things run in stops being magic.

One thread, with help

There is exactly one thread executing your JavaScript. When that thread calls fs.readFile or makes a network request, it does not sit and wait — it registers a callback and returns immediately, free to run other code. The waiting happens elsewhere:

  • Network I/O (sockets, HTTP) is handled by the OS kernel’s async facilities (epoll on Linux, kqueue on macOS, IOCP on Windows). libuv asks the kernel “tell me when this socket is readable” and the kernel does the watching. No extra thread needed.
  • Things the OS has no async API for — file system operations, dns.lookup, CPU-bound crypto (pbkdf2, scrypt) and zlib — are pushed onto the thread pool. These really do run on background threads, which is why the pool size (default 4) can become a bottleneck if you fire dozens of fs calls at once.

The event-loop phases, in order

Each turn (“tick”) of the loop runs through a fixed sequence of phases. Each phase has its own callback queue and a rule for what it processes:

PhaseWhat runs here
timerscallbacks from setTimeout / setInterval whose threshold has elapsed
pending callbacksa few deferred system callbacks (e.g. some TCP errors)
idle, prepareinternal-only (libuv housekeeping)
pollretrieve new I/O events; run their callbacks; block here waiting for I/O if nothing else is pending
checksetImmediate callbacks fire here, right after poll
close callbacks'close' events, e.g. socket.on('close', …)
timers → pending → (idle/prepare) → poll → check → close, then repeat.

The poll phase is the heart of it: when there’s no timer due and nothing queued for check, the loop parks in poll, blocking the thread on the kernel until an I/O event or timer wakes it. That blocking-while-idle is exactly why an idle Node process uses ~0% CPU yet responds instantly.

timerssetTimeoutpendingsys cbspollI/O · parkschecksetImmediateclose’close’BETWEEN EVERYCALLBACK:drain ALL ofprocess.nextTickthen ALL ofPromise jobs…then the next phase
FIG 1 · the loop One tick of the event loop, as a ring. The loop cycles through its phases clockwise — timers → pending → poll → check → close — then repeats. The crucial detail the ring can't quite show: between EVERY callback (not just between phases) the microtask queues drain completely — first the entire process.nextTick queue, then the entire Promise queue — before the loop runs the next callback. That microtask drain is why a resolved Promise's .then always runs before the next setTimeout.

Microtasks jump the queue

Phases handle macrotasks. But there are two higher-priority queues that get drained between every callback, not just between phases:

  1. The process.nextTick queue — drained first, completely.
  2. The Promise microtask queue (.then/.catch/.finally, and the continuation after every await) — drained next, completely.

After any single macrotask finishes, Node empties both microtask queues (nextTick, then Promises) before moving on. This is why a Promise.resolve().then(...) always runs before the next setTimeout, even a setTimeout(…, 0).

Predict the output

This is the canonical interview question. Read it, predict the order, then check.

Ordering: sync ▸ nextTick ▸ Promise ▸ timers/immediate
console.log("1: sync start");

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

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

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

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

console.log("6: sync end");

Output:

1: sync start
6: sync end
5: nextTick
4: promise then
2: setTimeout 0      // (may swap with 3 — see below)
3: setImmediate

Walking it through:

  • 1 then 6 — all synchronous top-level code runs first, to completion. setTimeout, setImmediate, .then, and nextTick only schedule callbacks; they don’t run them.
  • 5: nextTick — once the main script finishes, Node drains microtasks. The nextTick queue is drained before the Promise queue, so 5 beats 4.
  • 4: promise then — the Promise microtask queue drains next.
  • 2 and 3 — only now does the loop enter its phases. setTimeout(…, 0) lands in the timers phase; setImmediate lands in check. From the main module (not inside an I/O callback) their order is not guaranteed — it depends on whether the loop’s first tick has already passed the timer threshold, which is timing-sensitive. You’ll usually see 2 then 3, but it can flip.

The one rule that is guaranteed: inside I/O

The ambiguity above vanishes the moment you’re inside an I/O callback. There, the loop is already in the poll phase, and check comes right after poll while timers is a full lap away. So setImmediate always wins:

Inside an I/O callback, setImmediate always beats setTimeout(0)
const fs = require("node:fs");

fs.readFile(__filename, () => {
  setTimeout(() => console.log("timeout"), 0);
  setImmediate(() => console.log("immediate"));
});

Output — deterministically:

immediate
timeout

We’re inside the readFile callback, which runs in the poll phase. The very next phase is check (setImmediate), so immediate prints first. timeout has to wait for the loop to come all the way back around to timers on the next tick. This ordering is reliable; memorize it.

One more trace: await beats setTimeout(0)

An await continuation is just a Promise microtask, so it drains before the loop ever reaches the timers phase. This catches people who think await “yields to timers.”

Predict the output: await vs setTimeout(0)
async function main() {
  console.log("A: sync");
  setTimeout(() => console.log("B: timeout 0"), 0);
  await Promise.resolve();
  console.log("C: after await");
}
main();
console.log("D: after main() call");

Output:

A: sync
D: after main() call
C: after await
B: timeout 0

main() runs synchronously up to the await (A), where it suspends and returns control to the caller — so D prints next. The await Promise.resolve() continuation (C) is a microtask, drained the instant the synchronous stack empties. Only after every microtask is gone does the loop enter the timers phase and fire B. Microtasks always beat timers; an await is a microtask.

Blocking the loop

Everything above relies on one assumption: your callbacks return quickly. The event loop is cooperative — it can only move to the next callback when the current one returns. A synchronous CPU-heavy loop monopolizes the single thread, and during it nothing else runs: no timers, no I/O callbacks, no new connections accepted. Every client hangs.

01 Learning objectives

0 / 6 done

02 Curated reading

03 Knowledge check

knowledge check5 questions · pass ≥ 70%
  1. 01easy

    A long synchronous for-loop in a request handler blocks all other requests.

  2. 02medium

    What does this print?

    console.log('A');
    setTimeout(() => console.log('B'), 0);
    Promise.resolve().then(() => console.log('C'));
    process.nextTick(() => console.log('D'));
    console.log('E');
  3. 03medium

    After each operation, Node drains which queue FIRST?

  4. 04medium

    What actually runs Node's async I/O (fs, DNS, crypto) off the main thread?

  5. 05hard

    INSIDE an fs.readFile callback, which fires first?

04 Interview questions

browse all ↗

What gets asked on this topic — tap a card for how to approach it, the follow-ups, and the trap. Company tags are best-effort & sourced.

  • Commonly asked mid debug very common What prints, and in what order? console.log("A"); setTimeout(() => console.log("B"), 0); Promise.resolve().then(() => console.log("C")); process.nextTick(() => console.log("D")); console.log("E")

    A E D C B.

    First the synchronous code runs top to bottom: A, then E. The other three are deferred. Before the event loop advances to its next phase, Node drains its microtask queues, and process.nextTick has its own queue that runs before the Promise microtask queue, so D then C. Finally the setTimeout callback fires in the timers phase: B.

    The rule to memorize: nextTick > Promise microtasks > macrotasks (timers/immediate/I/O).

    Red flag Saying the order follows the source-code order, or putting `C` (Promise) before `D` (nextTick).

    source: Node.js docs — Event loop, timers, and nextTick ↗
  • Commonly asked mid debug very common At the top level of a module: setTimeout(() => console.log("timeout"), 0); setImmediate(() => console.log("immediate")). Which logs first?

    It is not guaranteed — the order is non-deterministic at the top level. setTimeout(0) is clamped to a 1ms timer, so whether the timers phase or the check phase reaches its callback first depends on how long process setup took. Run it twice and you may see different orders.

    The twist interviewers want: move both into an I/O callback, e.g. inside fs.readFile(...), and setImmediate always wins. After an I/O (poll-phase) callback, the loop goes straight to the check phase (setImmediate) before looping back to timers.

    Red flag Claiming setImmediate or setTimeout always wins at the top level — the whole point is that it is non-deterministic there.

    source: Node.js docs — setImmediate vs setTimeout ↗
  • Commonly asked mid concept very common Name the phases of the Node.js event loop in order, and say what runs in each.

    Six phases, run in this order each iteration ("tick"):

    1. timerssetTimeout/setInterval callbacks whose threshold has elapsed.
    2. pending callbacks — a few deferred system/OS callbacks (e.g. some TCP errors).
    3. idle, prepare — internal to libuv; you never schedule here.
    4. poll — retrieve new I/O events and run their callbacks; the loop may block here waiting for I/O.
    5. checksetImmediate callbacks.
    6. close callbacks — e.g. socket.on("close", ...).

    Between every callback (and between phases) Node drains the microtask queues: the process.nextTick queue first, then the Promise/queueMicrotask queue.

    Red flag Listing microtasks (Promises) as an event-loop phase — they are not; they run between phases.

    source: Node.js docs — Event loop, timers, and nextTick ↗
  • AmazonMetaTikTok mid concept very common Node.js is "single-threaded," yet it handles thousands of concurrent connections. How? Where do background threads come from?

    There is one JavaScript thread that runs all your code on the event loop. Concurrency comes from not waiting: when you do I/O (network, disk, DNS), Node hands the work to the OS or to libuv and registers a callback, then immediately moves on. When the I/O completes, its callback is queued and runs later on the JS thread.

    Most network I/O uses the OS's async primitives directly (epoll/kqueue/IOCP) — no extra thread. A few things that lack an async OS API run on libuv's thread pool (default size 4, UV_THREADPOOL_SIZE): file-system ops, DNS lookup, and some crypto/zlib work.

    So: one thread for JS, the OS + a small libuv pool for the blocking bits.

    Red flag Saying every async operation spawns a thread, or that the thread pool handles network sockets (it usually does not).

    source: Node.js docs — Don't block the event loop ↗
  • Commonly asked senior debug common What prints? console.log("start"); setTimeout(() => console.log("timeout"), 0); Promise.resolve().then(() => { console.log("promise1"); process.nextTick(() => console.log("nextTick-in-promise")); }); process.nextTick(() => console.log("nextTick")); console.log("end")

    start end nextTick promise1 nextTick-in-promise timeout.

    Sync first: start, end. Then the microtask drain begins. The nextTick queue runs to completion first: nextTick. Then the Promise queue: promise1 — which itself schedules a new nextTick. The drain is exhaustive: after the Promise queue, Node re-checks the nextTick queue and finds nextTick-in-promise, running it before leaving the microtask phase. Only once both microtask queues are empty does the loop reach timers: timeout.

    Key idea: microtasks added while draining are processed in the same drain, before any macrotask.

    Red flag Running `timeout` before `nextTick-in-promise` — newly-queued microtasks still drain before any timer.

    source: Node.js docs — Event loop, timers, and nextTick ↗
  • Commonly asked mid trick common What is the difference between process.nextTick() and setImmediate(), despite the confusing names?

    The names are backwards from what you would guess.

    - process.nextTick(cb) runs cb before the event loop continues — as soon as the current operation finishes, before returning to the loop. It is a microtask, higher priority than Promises. "Next tick" here means "before the next loop phase," i.e. almost immediately.
    - setImmediate(cb) schedules cb for the check phase of the *next* loop iteration. Despite "immediate," it is later than nextTick.

    Node docs themselves recommend setImmediate for most cases because it is easier to reason about and cannot starve the loop the way recursive nextTick can.

    Red flag Assuming setImmediate runs before nextTick because of the name — it is the opposite.

    source: Node.js docs — Understanding setImmediate() ↗
  • Commonly asked mid concept common A request handler runs a synchronous for-loop summing 1 to 10 billion. What happens to every other in-flight request, and why?

    Every other request stalls until the loop finishes. There is one JS thread, and a synchronous CPU-bound loop never yields to the event loop — no timers fire, no I/O callbacks run, no new connections are accepted. The whole server appears frozen.

    Fixes, in order of preference:

    1. Offload the CPU work to a Worker thread (or a child process / external service).
    2. Chunk the work and yield between chunks with setImmediate so the loop can service I/O.
    3. Push it out of the request path entirely (a job queue).

    The mental model: async I/O is free concurrency, but CPU work is not — it must be moved off the main thread.

    Red flag Thinking async/await or wrapping the loop in a Promise makes synchronous CPU work non-blocking — it does not.

    source: Node.js docs — Don't block the event loop ↗
  • Commonly asked mid debug common What prints? async function f() { console.log(1); await null; console.log(2); } console.log(3); f(); console.log(4)

    3 1 4 2.

    console.log(3) runs. Then f() is *called* and runs synchronously up to the await: it logs 1. At await null, the function suspends and its continuation (console.log(2)) is scheduled as a microtask; control returns to the caller, which logs 4. The synchronous stack is now empty, so the microtask queue drains: 2.

    The insight: code before the first await runs synchronously; everything after await is a microtask, even when you await an already-resolved value like null.

    Red flag Treating the body after `await` as still synchronous and printing `1 2` together.

    source: Lydia Hallie — JavaScript Visualized: Promises & Async/Await ↗
  • Commonly asked senior concept common Are microtasks (Promise callbacks) part of the event loop's phases? When exactly do they run?

    No — microtasks are not one of the libuv phases. There are two microtask queues (the process.nextTick queue, then the Promise/queueMicrotask queue) that Node drains completely between every callback and at each phase boundary.

    Concretely: run one callback from a phase, then fully drain nextTick, then fully drain Promises, then run the next callback. Because the drain is exhaustive, a flood of microtasks (or recursive nextTick) can delay the loop from ever reaching the next macrotask — a real starvation risk.

    In the browser the model is similar but there is only the Promise microtask queue (no nextTick).

    Red flag Describing microtasks as 'the last phase' of the loop — they interleave between callbacks, not at the end.

    source: Node.js docs — Event loop, timers, and nextTick ↗
  • ★ must-know Commonly asked mid debug common What prints, and in what order? console.log("A"); setTimeout(() => console.log("B"), 0); queueMicrotask(() => console.log("C")); Promise.resolve().then(() => console.log("D")); console.log("E")

    A E C D B.

    Sync code first: A, E. Then the microtask queue drains before any macrotask. queueMicrotask and Promise.resolve().then feed the same Promise/microtask queue, so they run in registration order: C was queued first, then D. Finally the setTimeout macrotask fires in the timers phase: B.

    The point: queueMicrotask is not a separate higher-priority queue like nextTick — it shares the Promise microtask queue and is the standards-based way to schedule a microtask.

    What a strong answer covers
    • Sync runs to completion first: A, then E.

    • queueMicrotask and Promise.then share one microtask queue, drained in FIFO/registration order: C then D.

    • All microtasks drain before any macrotask, so setTimeout's B is last.

    • Unlike process.nextTick, queueMicrotask has no priority over Promise callbacks — same queue.

    Quick self-check

    What is the output order?

    Red flag Treating queueMicrotask as a separate, higher-priority queue — it shares the Promise microtask queue and runs in registration order.

    source: MDN — queueMicrotask ↗
  • Commonly asked senior concept occasional What is UV_THREADPOOL_SIZE, what is its default, and what symptom tells you it's too small?

    UV_THREADPOOL_SIZE is the environment variable that sets the size of libuv's thread pool, which backs the handful of operations that lack an async OS API: file-system I/O, DNS lookup, and some crypto/zlib work. The default is 4.

    The symptom of it being too small: those specific operations start queuing behind each other even though the CPU is idle and the event loop is free. For example, fire 5 concurrent crypto.pbkdf2 calls with a pool of 4 and the 5th does not start until one of the first four finishes — added latency that looks mysterious because nothing is "blocked."

    Raise it (e.g. UV_THREADPOOL_SIZE=8) when you do heavy concurrent fs/crypto work, but it must be set before the pool is created (at process start).

    What a strong answer covers
    • Sets libuv's thread pool size; default 4.

    • Backs fs I/O, dns.lookup, and some crypto/zlibnot network sockets (those use the OS directly).

    • Symptom of too-small: those ops serialize/queue while CPU and event loop sit idle.

    • Must be set at process startup — changing it after the pool spins up has no effect.

    Red flag Raising the pool size to fix latency on network I/O — sockets don't use the pool, so it does nothing.

    source: Node.js docs — UV_THREADPOOL_SIZE ↗
  • Commonly asked senior debug common What prints? const fs = require("fs"); fs.readFile(__filename, () => { setTimeout(() => console.log("timeout"), 0); setImmediate(() => console.log("immediate")); });

    immediate then timeoutdeterministically, every run.

    The readFile callback runs in the poll phase. From the poll phase the loop advances next to the check phase, where setImmediate callbacks live — so immediate fires first. Only after wrapping back around to the timers phase does the setTimeout(0) callback run: timeout.

    This is the famous twist: at the top level setTimeout(0) vs setImmediate ordering is non-deterministic, but inside an I/O callback setImmediate always wins because check immediately follows poll.

    What a strong answer covers
    • The I/O callback runs in the poll phase; the loop goes poll → check → (wrap) → timers.

    • check (setImmediate) comes right after poll, so immediate runs before timeout.

    • This ordering is deterministic inside an I/O callback (unlike at the top level).

    • It demonstrates the phase order, not a race — setImmediate reliably beats setTimeout(0) here.

    Quick self-check

    What prints, and is it deterministic?

    Red flag Saying setTimeout wins or that it's non-deterministic — inside an I/O callback, setImmediate is guaranteed first.

    source: Node.js docs — setImmediate() vs setTimeout() ↗
  • Commonly asked senior concept occasional Can recursive process.nextTick() starve the event loop? Contrast with recursive setImmediate().

    Yes — recursive process.nextTick can starve the loop. The nextTick queue is drained completely between phases, and a callback that schedules another nextTick keeps re-filling that queue, so the loop never advances to timers, poll, or I/O. Your server stops accepting connections and firing timers while the CPU spins on nextTicks.

    Recursive setImmediate does not starve I/O. setImmediate callbacks run in the check phase, and each loop iteration runs the immediates queued *before* this iteration started — newly-scheduled ones wait for the *next* iteration. So the loop still visits the poll phase between iterations and services I/O.

    This is exactly why Node's docs recommend setImmediate over nextTick for deferring work in most cases.

    What a strong answer covers
    • nextTick queue drains fully between phases; recursive nextTick re-fills it and blocks the loop from advancing.

    • Recursive setImmediate yields each iteration — newly-queued immediates wait for the next tick, so I/O still runs.

    • Starvation symptom: timers don't fire and new connections aren't accepted while CPU is busy.

    • Docs recommend setImmediate for deferral precisely because it can't starve the loop.

    Red flag Using recursive nextTick for chunked work — it can lock out all I/O; use setImmediate to chunk safely.

    source: Node.js docs — process.nextTick() ↗
  • Commonly asked senior concept occasional How does the event loop in Node differ from the one in the browser? Name two concrete differences.

    They share the core idea — a single JS thread, a macrotask queue, and a microtask queue drained between tasks — but differ in details:

    1. Extra microtask queue: Node has process.nextTick, which runs before the Promise microtask queue. The browser has only the Promise/queueMicrotask queue.
    2. Phases and setImmediate: Node's loop is libuv's multi-phase loop (timers, poll, check, …) and exposes setImmediate (the check phase). The browser has no setImmediate; its closest analog is task scheduling via setTimeout/messaging, and rendering steps (style/layout/paint, requestAnimationFrame) are interleaved into its loop — Node has no rendering.

    So: Node = libuv phases + nextTick + setImmediate, no rendering; browser = task/microtask + a render step, no nextTick/setImmediate.

    What a strong answer covers
    • Node has two microtask queues (nextTick before Promises); the browser has only the Promise queue.

    • Node's loop has libuv phases and setImmediate; the browser has neither.

    • The browser interleaves rendering (rAF, style/layout/paint); Node has no render step.

    • Both: single JS thread, microtasks drain to empty between macrotasks.

    Red flag Assuming setImmediate or process.nextTick exist in the browser, or that the two loops are identical.

    source: MDN — The event loop ↗
  • Commonly asked mid debug common What prints? for (let i = 0; i < 3; i++) { setTimeout(() => console.log(i), 0); } for (var j = 0; j < 3; j++) { setTimeout(() => console.log(j), 0); }

    0 1 2 3 3 3.

    The first loop uses let, which is block-scoped: each iteration gets a fresh binding of i, so the three closures capture 0, 1, 2 respectively. The second loop uses var, which is function-scoped: all three closures capture the *same* j, and by the time the timers fire (after the synchronous loops finish) j is already 3 — so it prints 3 three times.

    This is the classic closures-in-a-loop trap. The timers all queue with delay 0 and fire in order after the synchronous code completes.

    What a strong answer covers
    • let is block-scoped → a fresh binding per iteration → captures 0, 1, 2.

    • var is function-scoped → one shared binding → all closures see the final value 3.

    • All callbacks are deferred (setTimeout), so they read the variable after the loop finishes.

    • Fix for var: an IIFE per iteration, or just use let.

    Quick self-check

    What is the output?

    Red flag Expecting both loops to print 0 1 2 — the var loop captures one shared, function-scoped binding.

    source: Lydia Hallie — javascript-questions ↗
  • Commonly asked senior concept occasional What is 'event-loop lag' (event-loop delay), why does it matter, and how do you measure it?

    Event-loop lag is the extra time between when a callback (e.g. a timer) was *supposed* to run and when it *actually* runs. A timer set for 0ms that fires 80ms late means the loop spent ~80ms busy elsewhere — almost always a synchronous, CPU-bound task blocking the single thread.

    It matters because it is the single best health signal for a Node service: high lag means requests are queuing and latency is spiking for *everyone*, even if CPU and memory look fine. It is the symptom of "don't block the event loop."

    Measure it precisely with the built-in perf_hooks.monitorEventLoopDelay() histogram (min/max/percentiles), or the crude classic: a recurring setInterval that records how far past its scheduled time it fires.

    What a strong answer covers
    • Lag = actual minus scheduled callback time; reflects how long the loop was busy.

    • High lag almost always means synchronous CPU work blocking the one JS thread.

    • It's a leading indicator of latency for all requests, not just one.

    • Measure with perf_hooks.monitorEventLoopDelay() (a histogram) or a self-timing setInterval.

    Red flag Diagnosing latency with CPU/memory only — a blocked loop can show low CPU yet high lag and timeouts.

    source: Node.js docs — perf_hooks.monitorEventLoopDelay ↗