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-boundcrypto(pbkdf2, scrypt) andzlib— 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 offscalls 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:
| Phase | What runs here |
|---|---|
| timers | callbacks from setTimeout / setInterval whose threshold has elapsed |
| pending callbacks | a few deferred system callbacks (e.g. some TCP errors) |
| idle, prepare | internal-only (libuv housekeeping) |
| poll | retrieve new I/O events; run their callbacks; block here waiting for I/O if nothing else is pending |
| check | setImmediate callbacks fire here, right after poll |
| close callbacks | 'close' events, e.g. socket.on('close', …) |
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.
Microtasks jump the queue
Phases handle macrotasks. But there are two higher-priority queues that get drained between every callback, not just between phases:
- The
process.nextTickqueue — drained first, completely. - The Promise microtask queue (
.then/.catch/.finally, and the continuation after everyawait) — 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.
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: setImmediateWalking it through:
1then6— all synchronous top-level code runs first, to completion.setTimeout,setImmediate,.then, andnextTickonly schedule callbacks; they don’t run them.5: nextTick— once the main script finishes, Node drains microtasks. ThenextTickqueue is drained before the Promise queue, so5beats4.4: promise then— the Promise microtask queue drains next.2and3— only now does the loop enter its phases.setTimeout(…, 0)lands in the timers phase;setImmediatelands 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 see2then3, 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:
const fs = require("node:fs");
fs.readFile(__filename, () => {
setTimeout(() => console.log("timeout"), 0);
setImmediate(() => console.log("immediate"));
});Output — deterministically:
immediate
timeoutWe’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.”
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 0main() 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 done02 Curated reading
03 Knowledge check
- 01easy
A long synchronous for-loop in a request handler blocks all other requests.
- 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'); - 03medium
After each operation, Node drains which queue FIRST?
- 04medium
What actually runs Node's async I/O (fs, DNS, crypto) off the main thread?
- 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.
-
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, thenE. The other three are deferred. Before the event loop advances to its next phase, Node drains its microtask queues, andprocess.nextTickhas its own queue that runs before the Promise microtask queue, soDthenC. Finally thesetTimeoutcallback fires in the timers phase:B.The rule to memorize: nextTick > Promise microtasks > macrotasks (timers/immediate/I/O).
Follow-ups they push on- Why does process.nextTick run before the Promise callback even though it was scheduled later in the code?
- What happens if a nextTick callback schedules another nextTick — can it starve the loop?
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 ↗ -
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(...), andsetImmediatealways wins. After an I/O (poll-phase) callback, the loop goes straight to the check phase (setImmediate) before looping back to timers.Follow-ups they push on- Why does setImmediate become deterministic once you nest both inside an fs.readFile callback?
- Where in the phase order do timers and check sit relative to the poll phase?
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 ↗ -
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. timers —
setTimeout/setIntervalcallbacks 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. check —setImmediatecallbacks.
6. close callbacks — e.g.socket.on("close", ...).Between every callback (and between phases) Node drains the microtask queues: the
process.nextTickqueue first, then the Promise/queueMicrotaskqueue.Follow-ups they push on- In which phase does the loop actually block waiting for work?
- Are microtasks a phase of the loop? (No — they drain between callbacks.)
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 ↗ -
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, DNSlookup, and somecrypto/zlibwork.So: one thread for JS, the OS + a small libuv pool for the blocking bits.
Follow-ups they push on- Which built-in operations actually use the libuv thread pool?
- What is UV_THREADPOOL_SIZE and when would you raise it?
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 ↗ -
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 findsnextTick-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.
Follow-ups they push on- Could this pattern (nextTick scheduling nextTick) starve the timers phase indefinitely?
- Where does queueMicrotask sit relative to process.nextTick?
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 ↗ -
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)runscbbefore 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)schedulescbfor the check phase of the *next* loop iteration. Despite "immediate," it is later thannextTick.Node docs themselves recommend
setImmediatefor most cases because it is easier to reason about and cannot starve the loop the way recursivenextTickcan.Follow-ups they push on- Why can recursive process.nextTick starve I/O but recursive setImmediate cannot?
- Which one would you use to defer work to 'after this function returns but before any I/O'?
Red flag Assuming setImmediate runs before nextTick because of the name — it is the opposite.
source: Node.js docs — Understanding setImmediate() ↗ -
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 withsetImmediateso 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.
Follow-ups they push on- How would you detect event-loop blocking in production? (event-loop lag / monitoring.)
- Why is Worker threads better than just adding more setTimeout calls here?
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 ↗ -
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. Thenf()is *called* and runs synchronously up to theawait: it logs1. Atawait null, the function suspends and its continuation (console.log(2)) is scheduled as a microtask; control returns to the caller, which logs4. The synchronous stack is now empty, so the microtask queue drains:2.The insight: code before the first
awaitruns synchronously; everything afterawaitis a microtask, even when you await an already-resolved value likenull.Follow-ups they push on- Does it matter that we awaited `null` instead of a real Promise? (No — await always yields.)
- Where would a process.nextTick scheduled in main code land relative to console.log(2)?
Red flag Treating the body after `await` as still synchronous and printing `1 2` together.
source: Lydia Hallie — JavaScript Visualized: Promises & Async/Await ↗ -
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.nextTickqueue, then the Promise/queueMicrotaskqueue) 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).Follow-ups they push on- How does this differ between Node and the browser?
- What is queueMicrotask and why prefer it over Promise.resolve().then for scheduling?
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 ↗ -
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.queueMicrotaskandPromise.resolve().thenfeed the same Promise/microtask queue, so they run in registration order:Cwas queued first, thenD. Finally thesetTimeoutmacrotask fires in the timers phase:B.The point:
queueMicrotaskis not a separate higher-priority queue likenextTick— it shares the Promise microtask queue and is the standards-based way to schedule a microtask.What a strong answer coversSync runs to completion first:
A, thenE.queueMicrotaskandPromise.thenshare one microtask queue, drained in FIFO/registration order:CthenD.All microtasks drain before any macrotask, so
setTimeout'sBis last.Unlike
process.nextTick,queueMicrotaskhas no priority over Promise callbacks — same queue.
Quick self-checkWhat is the output order?
-
Correct — sync (A,E), then microtasks in registration order (C,D), then the timer (B).
-
Wrong: C was queued before D, and they share one FIFO queue, so C precedes D.
-
Wrong: the setTimeout macrotask runs after the microtask queue is fully drained, not before.
-
Wrong: E is synchronous and runs before any microtask (C, D).
Follow-ups they push on- Where would a process.nextTick callback land relative to C and D?
- Why prefer queueMicrotask over Promise.resolve().then() for scheduling a microtask?
Red flag Treating queueMicrotask as a separate, higher-priority queue — it shares the Promise microtask queue and runs in registration order.
source: MDN — queueMicrotask ↗ -
What is UV_THREADPOOL_SIZE, what is its default, and what symptom tells you it's too small?
UV_THREADPOOL_SIZEis 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, DNSlookup, and somecrypto/zlibwork. 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.pbkdf2calls 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 coversSets libuv's thread pool size; default 4.
Backs fs I/O,
dns.lookup, and somecrypto/zlib— not 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.
Follow-ups they push on- Why doesn't raising UV_THREADPOOL_SIZE help an HTTP server doing pure network I/O?
- How would you tell pool saturation apart from event-loop blocking?
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 ↗ -
What prints? const fs = require("fs"); fs.readFile(__filename, () => { setTimeout(() => console.log("timeout"), 0); setImmediate(() => console.log("immediate")); });
immediatethentimeout— deterministically, every run.The
readFilecallback runs in the poll phase. From the poll phase the loop advances next to the check phase, wheresetImmediatecallbacks live — soimmediatefires first. Only after wrapping back around to the timers phase does thesetTimeout(0)callback run:timeout.This is the famous twist: at the top level
setTimeout(0)vssetImmediateordering is non-deterministic, but inside an I/O callbacksetImmediatealways wins because check immediately follows poll.What a strong answer coversThe I/O callback runs in the poll phase; the loop goes poll → check → (wrap) → timers.
check (setImmediate) comes right after poll, so
immediateruns beforetimeout.This ordering is deterministic inside an I/O callback (unlike at the top level).
It demonstrates the phase order, not a race —
setImmediatereliably beatssetTimeout(0)here.
Quick self-checkWhat prints, and is it deterministic?
-
Correct — poll is followed by check, so setImmediate runs before the loop wraps back to timers.
-
Wrong: timers come after check when entering from the poll phase, so setImmediate wins.
-
Wrong: that's only true at the top level; inside an I/O callback the phase order makes it deterministic.
-
Wrong: both callbacks run; the timer fires on the next loop iteration.
Follow-ups they push on- Why is the same pair non-deterministic at the top level of the module?
- Where does a process.nextTick scheduled inside the readFile callback run relative to these two?
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() ↗ -
Can recursive process.nextTick() starve the event loop? Contrast with recursive setImmediate().
Yes — recursive
process.nextTickcan 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
setImmediatedoes not starve I/O.setImmediatecallbacks 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
setImmediateovernextTickfor deferring work in most cases.What a strong answer coversnextTick 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.
Follow-ups they push on- Why does a newly-scheduled setImmediate wait for the next loop iteration but a newly-scheduled nextTick does not?
- When is process.nextTick still the right tool despite the starvation risk?
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() ↗ -
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/queueMicrotaskqueue.
2. Phases andsetImmediate: Node's loop is libuv's multi-phase loop (timers, poll, check, …) and exposessetImmediate(the check phase). The browser has nosetImmediate; its closest analog is task scheduling viasetTimeout/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, nonextTick/setImmediate.What a strong answer coversNode 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.
Follow-ups they push on- What's the browser's closest equivalent to setImmediate?
- Where does requestAnimationFrame sit relative to microtasks in the browser?
Red flag Assuming setImmediate or process.nextTick exist in the browser, or that the two loops are identical.
source: MDN — The event loop ↗ -
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 ofi, so the three closures capture0,1,2respectively. The second loop usesvar, which is function-scoped: all three closures capture the *same*j, and by the time the timers fire (after the synchronous loops finish)jis already3— so it prints3three 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 coversletis block-scoped → a fresh binding per iteration → captures 0, 1, 2.varis 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-checkWhat is the output?
-
Correct — let gives per-iteration bindings (0,1,2); var shares one binding, which is 3 by the time the timers fire.
-
Wrong: the var loop's closures all reference the same j, which has reached 3.
-
Wrong: the let loop creates a fresh binding each iteration, so it prints 0,1,2.
-
Wrong: after the var loop completes, j is 3 (the condition failed at 3), not 2.
Follow-ups they push on- How would you make the var loop print 0 1 2 without changing var to let?
- Would using Promise.resolve().then instead of setTimeout change the captured values?
Red flag Expecting both loops to print 0 1 2 — the var loop captures one shared, function-scoped binding.
source: Lydia Hallie — javascript-questions ↗ -
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 recurringsetIntervalthat records how far past its scheduled time it fires.What a strong answer coversLag = 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.
Follow-ups they push on- What's a healthy lag threshold for an HTTP service, and what would you alert on?
- How does monitorEventLoopDelay differ from just timing a 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 ↗