Timers (setTimeout, setInterval)
Timers schedule code to run later. They are global functions (no import needed), they come from Node — not V8 — and they are deeply tied to the event loop. Understanding their guarantees, and especially their non-guarantees, avoids a whole category of subtle timing bugs.
setTimeout — run once, later
const id = setTimeout(() => {
console.log('runs after ~1000ms')
}, 1000)
// Pass extra arguments straight to the callback:
setTimeout((name) => console.log(`Hi ${name}`), 500, 'Ada')
// Cancel before it fires:
clearTimeout(id)setInterval — run repeatedly
const tick = setInterval(() => {
console.log('every 2 seconds')
}, 2000)
clearInterval(tick) // stop it laterasync function poll() {
await doWork() // fully finishes first
setTimeout(poll, 2000) // then schedule the next run
}
poll()Where timers sit in the event loop
Expired timer callbacks run in the timers phase, at the top of each loop iteration — before I/O (poll) and setImmediate (check). This ordering is why setTimeout(fn, 0) and setImmediate can resolve in either order at the top level, but a fixed order inside an I/O callback. Full detail in setImmediate & process.nextTick.
The timers phase leads each turn
┌──► timers ← expired setTimeout / setInterval callbacks │ pending I/O │ poll ← most I/O callbacks │ check ← setImmediate └── close
Keeping or releasing the event loop
An active timer is a "ref'd" handle — it keeps the process alive so the callback can fire. Call .unref() so a timer will not, by itself, prevent the process from exiting; .ref() undoes it. This is how background heartbeats avoid hanging a CLI that is otherwise done:
const t = setTimeout(() => console.log('maybe'), 60_000)
t.unref() // if nothing else is pending, the process exits without waiting 60sPromise-based timers
The timers/promises API gives you awaitable timers — perfect for a non-blocking "sleep" in async code, with built-in AbortSignal cancellation:
import { setTimeout as sleep } from 'node:timers/promises'
console.log('start')
await sleep(1000) // pauses this async function without blocking the loop
console.log('1s later')
// Cancellable:
const ac = new AbortController()
setTimeout(() => ac.abort(), 500)
try {
await sleep(5000, undefined, { signal: ac.signal })
} catch {
console.log('sleep aborted early')
}start 1s later
Common pitfalls
Forgetting to
clearInterval→ a leak that keeps firing (and keeps the process alive) forever.Expecting precise timing → timers drift whenever the loop is busy.
Doing heavy CPU work inside a timer → blocks the loop exactly like anywhere else.
Using
setIntervalfor long async tasks → use recursivesetTimeoutinstead.A delay above ~24.8 days overflows the 32-bit field and fires immediately.