06 — Timers and the Event Loop — When Your Callback Actually Runs
"setTimeout, that's it" was my timer mental model, and the ordering surprises kept coming. The model that finally stuck is precise: Node.js has four ways to schedule a callback for "later," and each one queues the callback in a different phase of the event loop. The ordering rules between them are the difference between code that runs when I expect it and code that runs in an order I did not [1].
The framing that landed for me is to stop reading the names as English and start reading them as event-loop phases.
The four schedulers
- setTimeout(fn, ms) — run fn once, after at least ms milliseconds. Queues in the timers phase.
- setInterval(fn, ms) — run fn repeatedly every ms milliseconds, until clearInterval is called. Same timers phase.
- setImmediate(fn) — run fn once, on the next check phase of the event loop.
- process.nextTick(fn) — run fn after the current operation finishes, before the next event-loop tick even begins.
Two of these (setTimeout, setInterval) are about real time [2]. Two are about yielding back to the loop so other queued callbacks can run first. The naming is unfortunate — "immediate" and "next tick" both sound like "right now," but they mean different queues with a defined order.
The microtask versus macrotask distinction
The deeper rule is the microtask/macrotask split, which the Node docs describe in terms of phases [1]:
- Macrotasks — timers, I/O callbacks, setImmediate. The event loop runs one macrotask per tick of the loop, then drains all microtasks before moving to the next macrotask.
- Microtasks — promise callbacks (.then/.catch/.finally) and process.nextTick. These run between every macrotask, and process.nextTick runs before promise microtasks.
The practical consequence: between any two macrotasks, every queued nextTick and every promise callback fully drains. That is why a process.nextTick inside a handler delays the next I/O callback — microtasks are not free, they just run more eagerly.
setTimeout and setInterval
These are the time-based timers [2][3]. setTimeout(fn, 0) does not mean "run instantly"; it means "queue fn in the timers phase of a future tick." The actual delay is at least ms but may be longer if the loop is busy, since timers only fire when the loop reaches the timers phase and the threshold has passed. setInterval re-arms itself each time, making it the tool for polling, health checks, and scheduled cleanup — but it needs clearInterval when the work is done, or the callback leaks.
const id = setInterval(() => healthCheck(), 5000);
// later, when done:
clearInterval(id);setImmediate: yield to the loop
setImmediate(fn) schedules fn for the check phase — the part of the loop that runs after I/O callbacks [4]. The name is misleading: it does not run "immediately," it runs "after I/O events this tick." Its real job is to break a long synchronous computation into chunks without blocking the loop — yield with setImmediate, let pending I/O callbacks run, then continue. In a recursive handler, spawning the next iteration via setImmediate keeps the server responsive.
process.nextTick: before the next tick
process.nextTick(fn) is the most eagerly scheduled of all — it runs after the current operation completes but before the event loop moves to the next phase [5]. That makes it the highest-priority queue in Node.js, higher even than promise microtasks. The legitimate uses are narrow: ensuring a callback runs after the current synchronous code but before anything else can observe an inconsistent state (returning a value then emitting an event, for example). The danger is that a recursive process.nextTick starves the event loop entirely — because nextTicks drain before I/O, the loop never reaches the poll phase, and the process appears hung. Reach for setImmediate when in doubt.
Uncaught exceptions and the stack trace
The other piece worth nailing down here is what happens when something throws and nothing catches it. An uncaught exception is an error that propagates all the way out of the event loop with no handler — and by default, it crashes the process [6]. Node emits a process.on('uncaughtException', ...) event, but using that to swallow errors and keep running is considered bad practice: after an uncaught exception, the application state is unknown, and continuing risks data corruption. The accepted pattern is to log the error in the handler, then exit and let the process manager (PM2, systemd) restart the process in a clean state.
The stack trace is the diagnostic for any of these — the ordered list of function calls that led to the throw, printed with the error [7]. The stack trace is what points from a symptom back to the line that caused it, so preserving it (not re-wrapping errors without chaining) is worth the small effort.
How I use this
The model I keep is "which queue, and what drains when." A few habits fall out of it. I use setTimeout for real delays and setInterval only with a stored id and a clearInterval cleanup. I yield long loops with setImmediate, never process.nextTick, because nextTick can starve the loop. I treat uncaughtException as "log and crash," not "log and continue." And when callbacks fire in an order I did not expect, the first thing I check is which scheduler queued them — because "same tick" and "next tick" are not the same, and the names lie.
References
[1] OpenJS Foundation, "The Node.js event loop, timers, and process.nextTick()," nodejs.org. [Online]. Available: https://nodejs.org/learn/asynchronous-work/event-loop-timers-and-nexttick
[2] Mozilla, "setTimeout()," MDN Web Docs. [Online]. Available: https://developer.mozilla.org/en-US/docs/Web/API/setTimeout
[3] javascript.info, "Scheduling: setTimeout and setInterval." [Online]. Available: https://javascript.info/settimeout-setinterval
[4] OpenJS Foundation, "Understanding setImmediate()," nodejs.org. [Online]. Available: https://nodejs.org/en/learn/asynchronous-work/understanding-setimmediate
[5] OpenJS Foundation, "Understanding process.nextTick()," nodejs.org. [Online]. Available: https://nodejs.org/en/learn/asynchronous-work/understanding-processnexttick
[6] OpenJS Foundation, "Process: Event 'uncaughtException'," Node.js API Docs. [Online]. Available: https://nodejs.org/api/process.html#event-uncaughtexception
[7] cloudhadoop, "Multiple Ways to Log The Stack Trace in Node.js." [Online]. Available: https://www.cloudhadoop.com/nodejs/print-stack-trace-error/
Knowledge check · Question 1 of 5
What does `setTimeout(fn, 0)` actually do?
Comments
Leave a Comment
You must be signed in to comment
0 Comments
No comments yet. Be the first to comment!