---
title: "06 — Timers and the Event Loop — When Your Callback Actually Runs"
uid: timers-and-process-control
tags: ["event-loop", "settimeout", "setimmediate", "stack-trace", "nodejs", "timers", "roadmap:nodejs", "process-nexttick"]
excerpt: "setTimeout, setInterval, setImmediate, process.nextTick — four ways to schedule a callback, each queuing it in a different phase of the event loop. Ordering rules are the difference between expected and surprising."
date: 2026-08-13T03:27:56+0000
source: https://www.aveshina.my.id/en/blog/timers-and-process-control
---

"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.

```figure
<svg viewBox="0 0 740 280" xmlns="http://www.w3.org/2000/svg" class="my-6 w-full max-w-2xl" role="img" aria-label="Event loop tick ordering. A horizontal row labeled one tick shows: run one macrotask (a timer or setImmediate), then drain all process.nextTick callbacks, then drain all promise microtasks, then run the next macrotask. Microtasks always fully drain between macrotasks.">
  <g font-family="ui-sans-serif, system-ui, sans-serif" text-rendering="geometricPrecision">
    <text x="370" y="26" font-size="12" font-weight="700" fill="#1e1b4b" text-anchor="middle">one tick of the event loop</text>

    <!-- macrotask 1 -->
    <rect x="30" y="50" width="170" height="50" rx="8" fill="#e0e7ff" stroke="#6366f1" stroke-width="1.5"/>
    <text x="115" y="72" font-size="11" font-weight="700" fill="#1e1b4b" text-anchor="middle">one macrotask</text>
    <text x="115" y="89" font-size="10" font-family="ui-monospace, monospace" fill="#475569" text-anchor="middle">timer / I/O / setImmediate</text>

    <!-- nextTick drain -->
    <rect x="220" y="50" width="150" height="50" rx="8" fill="#fce7f3" stroke="#db2777" stroke-width="1.5"/>
    <text x="295" y="72" font-size="11" font-weight="700" fill="#500724" text-anchor="middle">drain nextTick</text>
    <text x="295" y="89" font-size="10" font-family="ui-monospace, monospace" fill="#500724" text-anchor="middle">microtask (highest)</text>

    <!-- promise drain -->
    <rect x="390" y="50" width="150" height="50" rx="8" fill="#fef9c3" stroke="#ca8a04" stroke-width="1.5"/>
    <text x="465" y="72" font-size="11" font-weight="700" fill="#422006" text-anchor="middle">drain promises</text>
    <text x="465" y="89" font-size="10" font-family="ui-monospace, monospace" fill="#422006" text-anchor="middle">microtask queue</text>

    <!-- next macrotask -->
    <rect x="560" y="50" width="150" height="50" rx="8" fill="#e0e7ff" stroke="#6366f1" stroke-width="1.5"/>
    <text x="635" y="72" font-size="11" font-weight="700" fill="#1e1b4b" text-anchor="middle">next macrotask</text>
    <text x="635" y="89" font-size="10" font-family="ui-monospace, monospace" fill="#475569" text-anchor="middle">loop repeats…</text>

    <!-- arrows -->
    <line x1="200" y1="75" x2="218" y2="75" stroke="#64748b" stroke-width="1.5"/>
    <polygon points="218,75 213,72 213,78" fill="#64748b"/>
    <line x1="370" y1="75" x2="388" y2="75" stroke="#64748b" stroke-width="1.5"/>
    <polygon points="388,75 383,72 383,78" fill="#64748b"/>
    <line x1="540" y1="75" x2="558" y2="75" stroke="#64748b" stroke-width="1.5"/>
    <polygon points="558,75 553,72 553,78" fill="#64748b"/>

    <!-- legend -->
    <rect x="30" y="150" width="680" height="110" rx="8" fill="#f8fafc" stroke="#cbd5e1" stroke-width="1"/>
    <text x="370" y="174" font-size="11" font-weight="700" fill="#1e293b" text-anchor="middle">ordering rules</text>
    <text x="50" y="198" font-size="10" font-family="ui-monospace, monospace" fill="#334155">process.nextTick  ▸ always runs first, before promises</text>
    <text x="50" y="216" font-size="10" font-family="ui-monospace, monospace" fill="#334155">promise .then     ▸ runs before the next macrotask</text>
    <text x="50" y="234" font-size="10" font-family="ui-monospace, monospace" fill="#334155">setImmediate      ▸ runs in the check phase, this tick</text>
    <text x="50" y="252" font-size="10" font-family="ui-monospace, monospace" fill="#334155">setTimeout(0)     ▸ runs in the timers phase, may be this or next tick</text>
  </g>
</svg>
```

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](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](https://developer.mozilla.org/en-US/docs/Web/API/setTimeout)

[3] javascript.info, "Scheduling: setTimeout and setInterval." [Online]. Available: [https://javascript.info/settimeout-setinterval](https://javascript.info/settimeout-setinterval)

[4] OpenJS Foundation, "Understanding setImmediate()," nodejs.org. [Online]. Available: [https://nodejs.org/en/learn/asynchronous-work/understanding-setimmediate](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](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](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/](https://www.cloudhadoop.com/nodejs/print-stack-trace-error/)

```quiz
Q: What does `setTimeout(fn, 0)` actually do?
- Runs fn instantly, inline
- Queues fn in the timers phase of a future tick of the event loop
correct: 1
explain: A 0ms delay still routes fn to the timers phase, which the loop only reaches after the current operation and pending microtasks finish. It is "next time we hit timers," not "right now."

Q: Which scheduled callback runs first within a single tick?
- A queued promise .then
- A queued process.nextTick
correct: 1
explain: process.nextTick has higher priority than promise microtasks. Both drain fully between macrotasks, but nextTick callbacks always run before promise callbacks within that drain.

Q: Why is recursive process.nextTick dangerous?
- It leaks memory
- It can starve the event loop, because nextTicks drain before I/O — the loop never reaches the poll phase
correct: 1
explain: Because process.nextTick runs before the loop advances, a recursive nextTick keeps re-queueing work that runs before any I/O callback gets a turn, freezing the process.

Q: What is the recommended response to an uncaughtException?
- Log it and continue serving requests — keep the process alive
- Log it, then exit and let the process manager restart the process
correct: 1
explain: After an uncaught exception the application state is unknown. The safe pattern is to log and exit so a clean process restarts, rather than continue in a possibly-corrupted state.

Q: You want to break a long synchronous loop into chunks without blocking I/O. Reach for…
- process.nextTick
- setImmediate
correct: 1
explain: setImmediate yields to the check phase, letting pending I/O callbacks run before the next chunk. process.nextTick would drain before I/O and keep the loop busy.
```
