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)runscallbackonce, after at leastdelaymilliseconds have elapsed.setInterval(callback, delay)runscallbackrepeatedly, roughly everydelaymilliseconds, until you cancel it.setImmediate(callback)runscallbackonce, right after the current poll phase finishes — it is not delay-based at all.process.nextTick(callback)runscallbackbefore 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 callbacks → idle/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/setTimeoutand callclearInterval/clearTimeoutonce 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
setTimeoutalone. - Prefer
node:timers/promises(setTimeout,setInterval,setImmediateas promises) forasync/awaitcode — it also accepts anAbortSignalso a pending delay can be cancelled cleanly. - Use
setImmediate, notsetTimeout(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
setImmediateor hand it off tonode:worker_threadsso timers and I/O keep firing on schedule. - Use
timeout.refresh()to restart an existing timer’s countdown instead of callingclearTimeoutfollowed by a brand-newsetTimeout.
Practice Exercises
- Write a script that logs “tick” every 300ms using
setInterval, and automatically stops itself withclearIntervalafter exactly 5 ticks, then logs “done”. - Using
node:timers/promises, write an async functionwaitAndLog(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. - Predict, then verify by running it, the console output order of a script that calls, in this order: a
setTimeout(fn, 0), asetImmediate(fn), aprocess.nextTick(fn), and aPromise.resolve().then(fn), all at the top level of the file.
Summary
setTimeoutruns once after at least the given delay;setIntervalrepeats it; both return a cancellableTimeouthandle.setImmediateruns once, right after the current poll phase, in the check phase — deterministically before any newly-setsetTimeout(fn, 0)when scheduled inside an I/O callback.process.nextTickcallbacks, 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/promisesgives you awaitable, cancellable delays that fit naturally intoasync/awaitcode.
