Timers
setTimeout
Section titled “setTimeout”setTimeout(fn, delay) schedules fn to run as a macrotask after at least delay milliseconds. The delay is a minimum — not a guarantee. If the call stack is busy when the timer fires, the callback waits until the stack is clear.
Node clamps any delay below 1 ms up to 1 ms — a delay of 0 or a negative number both end up firing after about 1 ms (a negative delay also logs a TimeoutNegativeWarning). The commonly cited “4 ms minimum” figure is a browser rule for timers nested 5+ levels deep — it does not apply to Node’s setTimeout.
var id = setTimeout(function() { console.log('Runs after at least 100 ms');}, 100);
// Cancel before it fires:clearTimeout(id);setInterval
Section titled “setInterval”setInterval(fn, delay) repeatedly schedules fn every delay ms. Use clearInterval to stop it.
var count = 0;var id = setInterval(function() { count++; console.log('tick', count); if (count >= 3) clearInterval(id);}, 500);setTimeout with delay 0
Section titled “setTimeout with delay 0”setTimeout(fn, 0) does not mean “run immediately.” It queues fn as a macrotask. Any pending microtasks (Promise callbacks, queueMicrotask) will run first.
setTimeout(function() { console.log('Timer'); }, 0);Promise.resolve().then(function() { console.log('Promise'); });// Output: Promise, then TimerqueueMicrotask
Section titled “queueMicrotask”To schedule a callback before the next macrotask, use queueMicrotask:
queueMicrotask(function() { console.log('This runs in the microtask queue, before any timer');});Runnable demo
Section titled “Runnable demo”This demo shows that setTimeout(fn, 0) still runs after a resolved Promise .then, because the timer callback is a macrotask and microtasks drain first.
Output is always: Promise ran first, then Timer ran second.
Promise-based timers: timers/promises and AbortSignal.timeout
Section titled “Promise-based timers: timers/promises and AbortSignal.timeout”node:timers/promises gives you a Promise-returning setTimeout — no callback, so it composes naturally with await, Promise.race, and an AbortSignal. Passing { signal } lets you cancel the wait early instead of letting it run to completion:
Output (verified on Node 24)
waiting 30ms with timers/promises...
done waiting
aborted: AbortErrorAbortSignal.timeout(ms) returns a signal that aborts itself after ms — a compact way to say “give up after this long” without wiring your own setTimeout + AbortController pair by hand. It works anywhere a signal is accepted: timers/promises, fetch, and any of your own code that checks signal.aborted or listens for 'abort'.
Mental Model
Section titled “Mental Model”Every timer API is a promise about a minimum wait, never an exact one: setTimeout/setInterval hand a callback to libuv’s timer phase, which only runs once the call stack is empty and every microtask has drained — the delay you pass is a floor, not a schedule. timers/promises’ setTimeout is the same underlying timer with a Promise wrapped around it instead of a callback, which is precisely why it can be cancelled with a signal (a callback has no way to say “never mind,” but a Promise can reject) — AbortSignal.timeout(ms) is just a pre-built signal that fires its own abort after ms, so you do not have to construct an AbortController and its own setTimeout by hand every time you need a deadline.
Trade-offs
Section titled “Trade-offs”The callback-based timers (setTimeout, setInterval) have no built-in way to say “give up waiting” — you track the ID yourself and call clearTimeout/clearInterval, and there is no equivalent for “stop waiting for this to resolve” once a callback has fired. The Promise-based timers/promises API costs one extra import and a signal object, but buys real cancellation and composes directly with Promise.race/Promise.all — worth it for anything with a real deadline (an upstream call, a job that must not run forever) and unnecessary overhead for a plain “run this later” callback that never needs to change its mind.
Common Misconceptions
Section titled “Common Misconceptions”- “setTimeout(fn, 0) runs before anything else queued.” It runs before the next macrotask, but strictly after every pending microtask (
process.nextTick, Promise reactions,queueMicrotask) — see the demo above, where the Promise always logs first. - “setTimeout(fn, 0) is basically the same as process.nextTick(fn).” They are in different queues entirely:
nextTick(and microtasks) drain completely after every single callback;setTimeout(fn, 0)waits for the next full pass through the timers phase, which can be many callbacks later under load. - “A 0 or negative delay means the callback runs synchronously.” It never does — even
setTimeout(fn, -100)is clamped to Node’s ~1 ms floor and still yields to the event loop first; nothing scheduled withsetTimeoutever runs on the current stack frame.
Reading AI-generated code
Section titled “Reading AI-generated code”AI-generated code sometimes uses setTimeout(fn, 0) as an informal substitute for “the next tick” or “right after this async operation finishes” — treating it as a synchronization primitive it was never designed to be.
🐛 Spot the bug
function loadUserThenGreet(fetchUser) {let user = null;fetchUser().then(function (u) { user = u; });
setTimeout(function () { // AI comment: "setTimeout(fn, 0) runs on the next tick, right after the fetch above" console.log('Hello, ' + user.name);}, 0);}Show the answer
setTimeout(fn, 0) is a macrotask with roughly a 1 ms floor — it has no relationship to how long fetchUser() actually takes. If fetchUser() does real I/O (a network call, a database query), user is almost certainly still null when the timeout fires, and user.name throws a TypeError. Even calling this “the next tick” is a misnomer: process.nextTick and Promise microtasks are a separate, earlier-draining queue that has nothing to do with setTimeout’s macrotask queue. The fix is to react to the actual Promise instead of guessing a delay: fetchUser().then(function (u) { console.log('Hello, ' + u.name); }).