Mission 04 · Node.js Internals Campaign

setTimeout() vs setImmediate() — phases, the timer heap, and the queues that jump them

The fourth lesson. Parts 5, 8 and 9 all end the same way: something becomes ready and the loop runs your callback. This part is about the loop itself — six phases in a fixed order, a timer heap that shares one syscall with I/O, and two queues that are not phases at all and drain between every single callback.

18 sections11 figuressix phasestwo queues that jump them
§ 0 · THE SHORT ANSWER FIRST

One thread, six queues, and two that jump the line

Parts 5, 8 and 9 all end in the same place. A worker finishes a read, a packet arrives, a parser completes a message — and then the loop runs your callback. Every one of those traces stops at the door of the same room. This part goes inside it first.

An iteration of the event loop is not magic and it is not concurrent. It is a function that walks six phases in a fixed order, drains a queue at each one, and blocks in exactly one of them. Everything you have ever scheduled lands in one of those six queues — except for two things, which are not phases at all and cut in front of all of them.

JAVASCRIPTTHE ORDERING NOBODY PREDICTS CORRECTLY
setTimeout(() => console.log("timeout"), 0);
setImmediate(() => console.log("immediate"));

// from the main module   -> the order changes between runs
// from inside an I/O cb  -> "immediate" wins, every single time

Both of those facts fall out of one mechanism, and neither is a special case. The loop always runs its phases in the same order. What changes is where in that order you were standing when you scheduled.

!Lock this in

An iteration of the event loop is ordinary machine code running on the main JS thread. Nothing about it is parallel. The only reason a Node server feels concurrent is that all of the waiting happens inside one syscall.

§ 1 · ONE TRIP AROUND

A while loop with a queue at every stop

Strip away the vocabulary and the loop is a while loop in C. It asks whether anything is left to wait for; if so it walks the phases, running whatever callbacks each phase owns. Then it goes round again.

Cuv_run — libuv, paraphrased
while (uv__loop_alive(loop) && !loop->stop_flag) {
  uv__update_time(loop);          // one clock read per iteration
  uv__run_timers(loop);           // phase 1
  uv__run_pending(loop);          // phase 2
  uv__run_idle(loop);             // phase 3
  uv__run_prepare(loop);
  timeout = uv_backend_timeout(loop);
  uv__io_poll(loop, timeout);     // phase 4 - BLOCKS HERE
  uv__run_check(loop);            // phase 5
  uv__run_closing_handles(loop);  // phase 6
}

Two details there matter more than they look. uv__update_time reads the clock once per iteration — every timer checked during this trip is compared against the same "now", not against a fresh reading. And uv_backend_timeout computes how long the loop may sleep, which is the hinge the rest of this part turns on.

!Lock this in

The loop runs while it has handles or requests outstanding. When the last one goes away uv__loop_alive returns false, the while loop ends, and your process exits on its own. Nobody calls exit.

§ 2 · ONE ITERATION, START TO FINISH

Six queues in a fixed order

Each phase owns a queue and drains it before the next begins. The order never varies and the loop never skips a phase — a phase with an empty queue still happens, it just costs a comparison.

The six phases, in order
§2 · one iteration
timers
setTimeout · setInterval
node
Runs every timer whose threshold has already elapsed, measured against the single clock reading taken at the top of this iteration. Note the tense: the loop does not wake AT your threshold, it checks when it arrives here.
pending callbacks
deferred system operations
kernel
A small phase for callbacks libuv postponed from the previous iteration — certain TCP errors on some platforms, for instance. You never schedule here.
idle / prepare
libuv internal
node
Internal bookkeeping immediately before the block. Node uses prepare handles internally; nothing you write lands here.
poll
epoll_wait — the loop blocks HERE
kernel
The waiting phase. It takes a timeout computed from the timer heap, sleeps in the readiness syscall, and runs whatever I/O callbacks became ready. Parts 8 and 9 live almost entirely inside this one phase.
check
setImmediate
node
Runs immediately after poll. That adjacency is the entire reason setImmediate is predictable from inside an I/O callback and setTimeout is not.
close
'close' handlers
node
Runs teardown callbacks — socket.on('close') and friends — after the handle itself has been torn down.
the loop sleeps in exactly one of these six

Notice what is missing from that list. There is no phase for promises and no phase for process.nextTick. Those two are handled differently, and the difference is the next panel.

§ 3 · NOT PHASES AT ALL

nextTick and microtasks cut the line

process.nextTick and resolved promise continuations do not live in the loop. They drain between callbacks — after the current one returns and before the loop takes another step, including between two callbacks of the same phase.

Where the two queues actually drain
§3 · between every callback
a phase callback returns
any phase, any callback
main
The instant your callback hands control back, before the loop moves on to anything at all.
drain the nextTick queue
to exhaustion
main
Everything in it, plus anything those callbacks add while it is draining. There is no budget and no cap on this queue.
drain the microtask queue
to exhaustion
main
Resolved promise continuations, under the same rule. In Node, nextTick drains first — that is a Node decision, not a JavaScript one.
only now: the next callback
or the next phase
node
The loop resumes exactly where it left off, one callback further on.
this happens between every single callback, not once per iteration
JAVASCRIPTTHE ORDER WITHIN ONE TICK
setTimeout(() => console.log("4 · timers phase"), 0);
setImmediate(() => console.log("5 · check phase"));
Promise.resolve().then(() => console.log("2 · microtask"));
process.nextTick(() => console.log("1 · nextTick"));
console.log("0 · synchronous");

// 0 · synchronous
// 1 · nextTick      <- both drain before the loop
// 2 · microtask        takes a single step
// 4 · timers phase  <- and only then does the loop start
// 5 · check phase
!Lock this in

Because both queues drain to exhaustion, and both can feed themselves while draining, either one can stop the loop from advancing again. That is not a bug — it is the direct consequence of the words "to exhaustion".

§ 4 · SETTIMEOUT DOES NOT MEAN "IN N MILLISECONDS"

The delay is a floor, never a promise

setTimeout(fn, 100) guarantees that at least 100ms will pass before fn runs. It guarantees nothing about the upper bound, because the callback can only run when the loop next reaches the timers phase — and the loop only gets there when whatever is running now gives control back.

JAVASCRIPTTHE DELAY IS A FLOOR
setTimeout(() => console.log("100ms?"), 100);

const until = Date.now() + 500;
while (Date.now() < until) {}       // 500ms of synchronous work

// prints at ~500ms - and nothing is broken

Nothing there is late in the sense of a missed deadline. The timer became due at 100ms and ran at the first moment the loop was allowed to look at it. Node did exactly what it promised; the promise is just weaker than most people read it as.

The same reasoning covers a smaller surprise: setTimeout(fn, 0) is clamped to 1ms internally. There is no zero-delay timer, and even if there were it would not run sooner — it would still wait for the timers phase.

§ 5 · WHY TEN THOUSAND TIMERS COST NOTHING

A list per duration, and one libuv timer per list

The obvious implementation — one OS timer per setTimeout — would be ruinous for a server holding thousands of idle connection timeouts. Node does something much cheaper, in two layers.

What actually holds your timers
§5 · two layers
JS: a map from duration to a list
lib/internal/timers.js
main
Every setTimeout(fn, 5000) in your program goes into the same 5000ms list. Timers of equal duration are inherently sorted by insertion order, so appending is O(1) and nothing is ever sorted.
one libuv timer per LIST, not per timer
the list head
node
Node arms a single libuv timer for the earliest item in each list. Ten thousand 5000ms timeouts cost one libuv timer between them.
libuv: a min-heap of due times
uv__timer_heap
node
Across all the durations, libuv keeps a heap. "Is anything due?" means reading the root — O(1), no matter how many timers exist.
appending is O(1) · checking is O(1) · nothing is scanned

This is why a process with tens of thousands of pending timers shows no measurable CPU cost for them. Nothing polls them and nothing iterates them. The loop asks one question of one heap root, once per iteration.

§ 6 · WHERE TIMERS AND I/O MEET

One number decides how long the process sleeps

The timers phase and the poll phase are not competing systems, and this is where that stops being a claim and becomes a mechanism. Before the loop blocks it asks uv_backend_timeout for a number, and that number is how long epoll_wait may sleep.

How long may the loop sleep?
§6 · the hinge
the same syscall serves both readiness and deadlines

Read the setImmediate case carefully. When one is pending the timeout is zero — the loop refuses to sleep at all, because it has work waiting in the check phase directly after poll. That single rule is most of what people find mysterious about setImmediate.

!Lock this in

There is no timer thread and no polling. A deadline and a socket are the same kind of event to this loop, because a deadline is expressed as the timeout argument of the syscall it was already going to make.

§ 7 · REPEATING IS NOT SCHEDULING

The interval starts when the callback ends

setInterval(fn, 1000) does not fire on a fixed wall-clock cadence. Node re-arms the timer after your callback returns, so the real period is your interval plus however long the callback took, plus however long the loop took to reach the timers phase.

JAVASCRIPTTHE DRIFT IS CUMULATIVE
let n = 0;
const started = Date.now();
setInterval(() => {
  n++;
  block(50);                            // 50ms of work in the callback
  console.log(n, Date.now() - started); // 1 1050 · 2 2100 · 3 3150 ...
}, 1000);

Fifty milliseconds a tick sounds harmless until you notice it is never paid back. After an hour that clock is three minutes slow and drifting further every tick. If you need a cadence rather than a delay, compute the next deadline from a fixed origin and use setTimeout to hit it — do not accumulate.

!Lock this in

An interval is a delay that re-arms, not a schedule. If the callback takes longer than the interval the fires do not queue up and they do not overlap — they simply happen less often than you asked, and the drift never recovers.

§ 8 · SETTIMEOUT(0) VS SETIMMEDIATE

The answer depends on where you were standing

From the main module the order of these two is genuinely unstable — run it repeatedly and it will change. From inside an I/O callback it is completely deterministic and setImmediate always wins. Same two lines, two different answers.

JAVASCRIPTSAME TWO LINES, TWO DIFFERENT ANSWERS
// main module - unstable
setTimeout(() => console.log("timeout"), 0);
setImmediate(() => console.log("immediate"));

// inside an I/O callback - immediate ALWAYS first
fs.readFile(__filename, () => {
  setTimeout(() => console.log("timeout"), 0);
  setImmediate(() => console.log("immediate"));
});

From the main module the loop has not started yet. By the time it reaches the timers phase, either the 1ms clamp has elapsed or it has not — and which of those is true depends on how long process startup happened to take. Microseconds decide it, which is why the order is unstable rather than merely undocumented.

Inside an I/O callback there is nothing to decide. An I/O callback runs in the poll phase; the very next phase is check. The timers phase is a whole iteration away.

Where were you standing when you scheduled?
§8 · run the loop
the third run is the one where stepping the loop produces no loop at all
§ 9 · TWO JUMPERS, IN ORDER

nextTick drains before the microtask queue

Both queues jump the loop, so between them there has to be a rule. In Node the nextTick queue drains completely first and only then the microtask queue — and if a nextTick callback schedules a promise, or a promise callback schedules a nextTick, the cycle repeats until both are empty.

JAVASCRIPTINTERLEAVED, BUT NOT ALTERNATING
process.nextTick(() => {
  console.log("A · nextTick");
  Promise.resolve().then(() => console.log("C · promise from nextTick"));
});
Promise.resolve().then(() => console.log("B · promise"));
process.nextTick(() => console.log("A2 · nextTick"));

// A · nextTick
// A2 · nextTick              <- the whole nextTick queue first
// B · promise
// C · promise from nextTick  <- added during the drain, still runs now

This ordering is a Node decision, not part of the language. A browser has no process.nextTick at all and the microtask queue is the only jumper. Code that relies on nextTick beating promises is code that will not port.

!Lock this in

If you reach for process.nextTick to fix an ordering problem, the ordering problem is usually the real bug. The queue exists so Node can let you attach handlers before an event fires — not as a general-purpose "run this a bit sooner".

§ 10 · THE QUEUE THAT NEVER EMPTIES

One recursive nextTick and nothing else ever runs

Both jump-the-queue queues drain to exhaustion and both can feed themselves while draining. A process.nextTick that schedules another one therefore never lets the loop advance a single phase.

JAVASCRIPTTHE PROCESS IS BUSY AND NOTHING HAPPENS
setTimeout(() => console.log("never printed"), 0);
const server = net.createServer(handler);   // never accepts anything

function spin() { process.nextTick(spin); }
spin();

// 100% CPU · no timers · no I/O · no close handlers · forever

Nothing throws and nothing warns. The process is at full CPU doing real work and every other queue in the system is frozen behind it. A health check against that server does not fail slowly — it is never answered at all, because the accept queue (Part 8) is filling up behind a thread that will never call accept() again.

The same shape with setImmediate is harmless by comparison: it schedules into the check phase, so each recursion costs one full iteration and timers and I/O still get their turn. That difference — starve versus yield — is the practical reason to reach for setImmediate when you want to break work up.

§ 11 · YOU DO NOT NEED A QUEUE TO BLOCK THE LOOP

A long synchronous function does it just as well

Starvation gets attention because it looks exotic. The ordinary version is far more common and does exactly the same damage: a synchronous function that takes a long time.

Three ways to stop the loop
§11 · same outcome, different cause
a recursive process.nextTick
the queue never empties
worker
The loop never gets control back between callbacks, so no phase advances.
a long synchronous function
JSON.parse of 50MB · a tight loop · sync crypto
worker
The loop cannot advance because your callback has not returned. Identical symptom, and much more common in real code.
a blocking syscall on the main thread
readFileSync on a cold file
kernel
Part 5's control case. The thread is parked in the kernel rather than busy, so CPU sits at zero — but nothing runs either way.
busy or parked, the loop is equally stopped
!Lock this in

Every part of this series has now landed on the same sentence from a different direction. There is one thread that runs your JavaScript. Whatever holds it — a queue that refills, a function that will not return, or a syscall that will not come back — stops everything else in the process.

§ 12 · WHY YOUR SCRIPT DOES NOT EXIT

The loop is alive while it has handles

A Node program does not end when the last line of your file runs. It ends when the loop has nothing left to wait for — and "nothing left" has a precise meaning: no active handles and no active requests.

What counts as a reason to stay up
§12 · the alive test
an active handle
server · socket · timer · watcher
node
A long-lived thing registered with the loop. A listening server is the obvious one, and it is exactly why an HTTP server does not exit after listen() returns.
an active request
an in-flight fs or dns operation
node
A short-lived operation in progress — Part 5's uv_fs_t is one of these while the worker is reading.
a pending timer
setTimeout · setInterval
main
A timer counts. An uncleared setInterval holds a process open forever, which is the commonest cause of a script that will not exit.
none of the above
uv__loop_alive returns false
node
The while loop ends, beforeExit fires, and the process exits with code 0. Nobody called process.exit().
this is a count, not a guess
JAVASCRIPTTHE COMMONEST "WHY WON'T IT EXIT"
const t = setInterval(() => {}, 1000);   // holds the process open forever
// clearInterval(t)   <- or t.unref(), see the next panel
§ 13 · A HANDLE THAT DOES NOT VOTE

Keep working; just do not be the reason we stay up

unref() does not stop a handle from working. It removes the handle from the aliveness count, which says: carry on doing your job, but do not be the reason this process stays alive.

JAVASCRIPTA TIMER THAT DOES NOT HOLD THE DOOR
const beat = setInterval(() => metrics.flush(), 30_000);
beat.unref();

// the heartbeat still fires while the server is running,
// and the process still exits cleanly when the server closes

That is exactly the right tool for a background heartbeat, a metrics flush or a cache sweep: work that should happen while the program is alive and should never be what keeps it alive. It is also the fix for the interval in the previous panel.

The mirror image is ref(), and the pair matters in tests more than anywhere else — a test runner that hangs after the last assertion is almost always one un-unrefed handle that nobody closed.

§ 14 · WHAT SETTIMEOUT GIVES BACK

Not an id — a handle with methods

In a browser setTimeout returns a number. In Node it returns a Timeout object, and the difference is useful.

What you actually get back
§14 · the return value
Timeout { _idleTimeout, _onTimeout, ... }
not a number
main
An object holding the callback, the duration and its position in the duration list. Logging one shows you exactly what it is keeping alive.
.unref() / .ref()
vote or abstain
node
Remove or restore this handle from the aliveness count, as in the previous panel.
.refresh()
restart the countdown
node
Resets the timer to its full duration without allocating a new one. This is how idle-connection timeouts are implemented efficiently — one object refreshed on every byte, rather than a new timer per byte.
Symbol.toPrimitive
still works as an id
main
It coerces to a number, which is why clearTimeout accepts either and why browser-shaped code keeps working unchanged.
refresh() is why an idle timeout costs one allocation, not thousands
§ 15 · EVERYTHING IN ONE PLACE

One iteration, with both jumpers drawn in

Put the six phases and the two queues on one page and the ordering questions stop being puzzles. Every scheduling primitive in Node has exactly one home in this diagram.

Where every primitive lands
§15 · the full map
synchronous code
right now, on the stack
main
Runs to completion before anything below it gets a turn.
process.nextTick
between callbacks · first
main
Drains to exhaustion after the current operation, before the loop advances.
promises · queueMicrotask
between callbacks · second
main
The same slot, drained after nextTick, also to exhaustion.
setTimeout · setInterval
timers phase
node
At or after the threshold, the next time the loop reaches the phase.
I/O callbacks
poll phase
kernel
When the kernel says the fd is ready — Parts 5, 8 and 9, all of them.
setImmediate
check phase
node
Once per iteration, directly after poll.
'close' handlers
close phase
node
After the handle is gone.
seven primitives · six phases · two queues that are not phases
§ 16 · THE MODELS WORTH CORRECTING

Nine claims, and the mechanism behind each

These are the beliefs that produce the bugs. Each is a reasonable reading of the API surface, and each is wrong for the same underlying reason.

Which of these are true?
§16 · mental models
sort before you check — a guess you have committed to is worth more than a correction you read
!Lock this in

Every wrong take here assumes one of three things that is not there: a clock that interrupts you, a thread that runs your callback while you are busy, or a queue with a budget. Take those three away and the rest of the model follows.

§ 17 · THE FOUR SENTENCES WORTH KEEPING

If you remember nothing else

The loop walks six phases in a fixed order and blocks in exactly one of them. That is the whole architecture; everything else is detail about which queue a particular function writes to.

What to carry away
§17 · the four sentences
a delay is a floor, never a promise
§4
main
Your callback runs at or after the threshold, the next time the loop reaches the timers phase. If something is holding the thread, that is later.
the poll timeout IS the timer mechanism
§6
kernel
Deadlines and sockets are the same kind of event, because a deadline is expressed as the timeout argument of the syscall the loop was already making.
nextTick and promises are not phases
§3
main
They drain between callbacks, to exhaustion, before the loop takes a step — which is what makes starving it possible.
one thread runs your JavaScript
§11
worker
Busy or parked, whatever holds it stops everything. Parts 5, 8, 9 and 4 all end here.
four sentences, one thread

Part 10 takes the last of those seriously and asks the obvious next question: if one thread is the constraint, what happens when you ask for another one? The answer is not the thread you are expecting.

Back to JIT-Atlas