Skip to content

Timers

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(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(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 Timer

To schedule a callback before the next macrotask, use queueMicrotask:

queueMicrotask(function() {
console.log('This runs in the microtask queue, before any timer');
});

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.

JavaScript

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:

Node.js
Output (verified on Node 24)
waiting 30ms with timers/promises...
done waiting
aborted: AbortError

AbortSignal.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'.

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.

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.

  • “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 with setTimeout ever runs on the current stack frame.

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); }).

Is setTimeout(fn, 0) guaranteed to run fn immediately after the current synchronous code?
How do you stop a setInterval from firing again?
Which function schedules a callback in the microtask queue (not a timer queue)?
What does this print?
import { setTimeout as delay } from 'node:timers/promises';
try {
  await delay(1000, undefined, { signal: AbortSignal.timeout(10) });
  console.log('finished');
} catch (err) {
  console.log('caught:', err.name);
}
An AI-written function does setTimeout(() => console.log(user.name), 0) right after calling fetchUser().then(u => user = u), with the comment "runs on the next tick, right after the fetch." What is wrong with this reasoning?