Node.js Timers

Node.js gives you four timer globals: setTimeout, setInterval, setImmediate, and process.nextTick. They look like the browser APIs you already know, but in Node they are wired directly into the event loop that the libuv library drives, so knowing exactly when each one fires is essential for writing correct server code. Get the ordering wrong and you ship subtle bugs: a retry that fires before it should, an interval that never stops and keeps your process alive forever, or a timer that is hours late because something else blocked the loop.

Overview: How Timers Work in Node.js

All four timer functions schedule a callback to run later, but they differ in how much later and on what basis:

  • setTimeout(callback, delay) runs callback once, after at least delay milliseconds have elapsed.
  • setInterval(callback, delay) runs callback repeatedly, roughly every delay milliseconds, until you cancel it.
  • setImmediate(callback) runs callback once, right after the current poll phase finishes — it is not delay-based at all.
  • process.nextTick(callback) runs callback before the event loop continues to its next phase, ahead of everything else, including promises.

Timers and the event loop

Node’s event loop, implemented by libuv, cycles through phases in this order: timers (due setTimeout/setInterval callbacks) → pending callbacksidle/prepare (internal) → poll (I/O callbacks, and where the loop waits if nothing else is due) → check (setImmediate callbacks) → close callbacks. After every single callback anywhere in that cycle, Node fully drains the process.nextTick queue, and then fully drains the promise microtask queue, before doing anything else — including moving to the next phase or running the next macrotask. That is why nextTick and resolved promises always “cut in line” ahead of timers.

An important detail people miss: timers do not use the libuv thread pool. The thread pool exists for operations that don’t have a native async OS API (parts of fs, DNS lookups, crypto.pbkdf2, zlib compression). Timers are pure bookkeeping — libuv keeps a min-heap of due times and checks it directly on each loop iteration, so scheduling a million timers doesn’t touch a single worker thread; it’s just cheap comparisons in the timers phase.

Also remember that a delay is a minimum, never a guarantee. Node is single-threaded for your JavaScript: if a synchronous operation is running, no timer, no I/O callback, and no promise callback can run until it finishes, no matter how “due” they are. A setTimeout(fn, 100) can easily fire at 2000ms if something blocks the loop for two seconds in between.

Syntax

The core timer functions all follow the same basic shape:

const id = setTimeout(callback, delayMs, ...argsForCallback);
const id2 = setInterval(callback, delayMs, ...argsForCallback);
const id3 = setImmediate(callback, ...argsForCallback);
process.nextTick(callback, ...argsForCallback);

clearTimeout(id);
clearInterval(id2);
clearImmediate(id3);
Function Fires Cancel with
setTimeout(fn, ms) Once, after at least ms milliseconds clearTimeout(id)
setInterval(fn, ms) Repeatedly, roughly every ms milliseconds clearInterval(id)
setImmediate(fn) Once, in the check phase, right after the current poll phase clearImmediate(id)
process.nextTick(fn) Once, before the loop continues to any other phase not cancellable

Both setTimeout and setInterval return a Timeout object (not a plain number, unlike browsers) with two useful methods: timeout.unref() lets the process exit even if this timer is still pending, and timeout.ref() undoes that. timeout.refresh() resets a timer’s delay without the overhead of creating a brand-new one.

Examples

Example 1: Scheduling and cancelling a timeout

const timeoutId = setTimeout(() => {
  console.log("This runs after 2 seconds");
}, 2000);

console.log("Scheduled a timeout that will fire in 2 seconds");

// clearTimeout(timeoutId); // uncomment to cancel before it fires
Scheduled a timeout that will fire in 2 seconds
(2 seconds pass)
This runs after 2 seconds

setTimeout returns immediately with a Timeout handle; the synchronous console.log below it runs first, and the scheduled callback only runs once at least 2000ms have elapsed and the call stack is empty. Calling clearTimeout(timeoutId) before that point cancels it entirely — the callback never runs.

Example 2: Repeating work with setInterval

let count = 0;

const intervalId = setInterval(() => {
  count++;
  console.log(`Tick ${count}`);

  if (count === 3) {
    clearInterval(intervalId);
    console.log("Stopped after 3 ticks");
  }
}, 500);
Tick 1
Tick 2
Tick 3
Stopped after 3 ticks

Every setInterval needs an exit condition. Here the callback counts its own invocations and calls clearInterval once it hits 3; without that call the interval would fire forever, roughly every 500ms, and the Node process would never exit on its own.

Example 3: Awaitable delays with node:timers/promises

Since Node 15, the built-in node:timers/promises module gives you promise-based versions of these timers, which read much better with async/await than nesting callbacks — especially for retry-with-backoff logic.

const { setTimeout: sleep } = require("node:timers/promises");

async function fetchWithRetry(url, attempts = 3) {
  for (let attempt = 1; attempt <= attempts; attempt++) {
    try {
      const res = await fetch(url);
      if (!res.ok) throw new Error(`HTTP ${res.status}`);
      return await res.json();
    } catch (err) {
      console.log(`Attempt ${attempt} failed: ${err.message}`);
      if (attempt === attempts) throw err;
      await sleep(attempt * 500); // backoff: 500ms, then 1000ms
    }
  }
}

async function main() {
  try {
    const data = await fetchWithRetry("https://example.com/api/status");
    console.log("Got data:", data);
  } catch (err) {
    console.error("All attempts failed:", err.message);
  }
}

main();
Attempt 1 failed: HTTP 503
Attempt 2 failed: HTTP 503
Got data: { status: "ok" }

The exact output depends on whether and when the endpoint succeeds, but the shape is fixed: each failed attempt logs a message and then await sleep(...) pauses this async function — without blocking the rest of the process — before retrying with a longer backoff.

How It Works Step by Step (Under the Hood)

Ordering: nextTick, promises, and timers

console.log("start");

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

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

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

console.log("end");
start
end
nextTick
promise
timeout

Step by step: (1) the synchronous code runs top to bottom, printing start and end and just registering the other three callbacks. (2) Once the current script finishes, Node drains the process.nextTick queue completely — printing nextTick. (3) Node then drains the promise microtask queue completely — printing promise. (4) Only now does the loop enter the timers phase, where the due setTimeout callback finally runs, printing timeout. This happens even though the timeout’s delay was 0 — a timer callback can never run before the current synchronous code, nextTick queue, and microtask queue have all finished.

setImmediate vs. setTimeout(fn, 0)

At the very top of a script, the relative order of a zero-delay setTimeout and a setImmediate is not guaranteed — it depends on how fast the process finished initializing before the timers phase was first checked. But inside an I/O callback, the order is guaranteed, because the poll phase always hands off to the check phase (where setImmediate lives) before looping back around to timers:

const fs = require("node:fs");

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

Because this code runs inside an fs.readFile callback (the poll phase), the loop moves straight to the check phase next, firing setImmediate first, and only reaches the timers phase on its next full cycle. If you need “as soon as possible after this I/O finishes” semantics, setImmediate is the more predictable choice.

Common Mistakes

Mistake 1: Assuming a zero-delay timeout runs immediately

console.log("A");
setTimeout(() => console.log("B"), 0);
console.log("C");
A
C
B

A delay of 0 does not mean “run now” — it means “run as soon as the timers phase is next reached,” which is always after the current synchronous code has finished. If you actually need code to run synchronously, just call it directly instead of wrapping it in setTimeout.

Mistake 2: Forgetting to clear an interval

// Wrong: no exit condition, and no reference kept for cleanup
setInterval(() => {
  console.log("still running...");
}, 1000);

This process never exits on its own — an active setInterval keeps the event loop alive forever, which is a real problem in a CLI tool or a script meant to finish, and can leak memory in long-running servers if you keep creating new intervals without ever clearing old ones (for example, one per incoming request).

const id = setInterval(() => {
  console.log("still running...");
}, 1000);

// Somewhere the interval should actually stop:
process.on("SIGINT", () => {
  clearInterval(id);
  process.exit(0);
});

Always keep the id returned by setInterval and call clearInterval once the repeating work is no longer needed. If the timer is just a background helper that shouldn’t block the process from exiting at all, call id.unref() instead.

Mistake 3: Blocking the event loop delays every timer

const start = Date.now();

setTimeout(() => {
  console.log(`Fired after ${Date.now() - start}ms (requested 100ms)`);
}, 100);

// Simulate 2 seconds of synchronous, CPU-bound work
const blockUntil = Date.now() + 2000;
while (Date.now() < blockUntil) {
  // busy-wait: nothing else, including the timer above, can run
}
Fired after approximately 2000ms (requested 100ms)

The while loop is pure synchronous JavaScript, so it monopolizes the single thread Node runs your code on. The timer was due at 100ms, but it can’t fire until the busy-wait finishes almost two seconds later. In real code, the culprit is usually a large synchronous JSON parse, a giant array sort, or a `*Sync` file operation, not a literal busy-wait. The fix is to break heavy synchronous work into smaller chunks and yield back to the loop between them, or move it off the main thread with node:worker_threads:

function processChunk(items, index, chunkSize, done) {
  const end = Math.min(index + chunkSize, items.length);

  for (let i = index; i < end; i++) {
    // do a small piece of work for items[i]
  }

  if (end < items.length) {
    setImmediate(() => processChunk(items, end, chunkSize, done));
  } else {
    done();
  }
}

By calling setImmediate between chunks instead of looping over the whole array in one synchronous pass, other pending timers and I/O callbacks get a chance to run between chunks.

Best Practices

  • Always store the id from setInterval/setTimeout and call clearInterval/clearTimeout once it’s no longer needed, especially inside request handlers or loops that create timers repeatedly.
  • Call .unref() on housekeeping timers (health checks, cache-sweeps) that shouldn’t by themselves keep the process alive; call .ref() to undo it if needed.
  • Treat every delay as a minimum, never an exact time — never build timing-sensitive logic (like animation frames or precise scheduling) on setTimeout alone.
  • Prefer node:timers/promises (setTimeout, setInterval, setImmediate as promises) for async/await code — it also accepts an AbortSignal so a pending delay can be cancelled cleanly.
  • Use setImmediate, not setTimeout(fn, 0), when you specifically mean “run right after the current I/O callback finishes” — its ordering there is deterministic.
  • Never do heavy, purely synchronous work on the main thread; chunk it with setImmediate or hand it off to node:worker_threads so timers and I/O keep firing on schedule.
  • Use timeout.refresh() to restart an existing timer’s countdown instead of calling clearTimeout followed by a brand-new setTimeout.

Practice Exercises

  1. Write a script that logs “tick” every 300ms using setInterval, and automatically stops itself with clearInterval after exactly 5 ticks, then logs “done”.
  2. Using node:timers/promises, write an async function waitAndLog(message, ms) that logs a start message, awaits the given delay, then logs the message. Call it three times with different delays and observe that they don’t block each other if started without awaiting each call in sequence.
  3. Predict, then verify by running it, the console output order of a script that calls, in this order: a setTimeout(fn, 0), a setImmediate(fn), a process.nextTick(fn), and a Promise.resolve().then(fn), all at the top level of the file.

Summary

  • setTimeout runs once after at least the given delay; setInterval repeats it; both return a cancellable Timeout handle.
  • setImmediate runs once, right after the current poll phase, in the check phase — deterministically before any newly-set setTimeout(fn, 0) when scheduled inside an I/O callback.
  • process.nextTick callbacks, then resolved promise callbacks, always run before the event loop advances to the next phase — ahead of every timer.
  • Timers don’t use the libuv thread pool; they’re a min-heap checked directly in the event loop’s timers phase.
  • A delay is a minimum, not a guarantee — synchronous work anywhere in your code delays every pending timer.
  • Always clear intervals/timeouts you no longer need, and use .unref() for timers that shouldn’t keep the process running.
  • node:timers/promises gives you awaitable, cancellable delays that fit naturally into async/await code.