NodeJSTimers (setTimeout, setInterval)

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

JS
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)
The delay is a minimum, not a promise
`setTimeout(fn, 1000)` means "run `fn` no *sooner* than 1000ms from now" — not "exactly at 1000ms." If the event loop is busy with a long synchronous task when the timer expires, the callback waits its turn. Timers are best-effort scheduling, never real-time.
The 1ms floor and the 2³¹ ceiling
A delay of `0` is clamped to a **1ms minimum**, and nested timers are clamped to 4ms after several levels (a browser legacy Node keeps). The delay is also a **32-bit signed int**: anything above 2,147,483,647 ms (~24.8 days) overflows and is treated as `1`, firing almost immediately — a classic "my timer ran instantly" bug.
setInterval — run repeatedly

JS
const tick = setInterval(() => {
  console.log('every 2 seconds')
}, 2000)

clearInterval(tick)   // stop it later
setInterval drifts and can stack up
The interval counts from when each callback was *scheduled*, not when it *finished*. If the loop is busy, ticks bunch up and fire back-to-back to "catch up." Worse, if each tick starts async work that runs longer than the interval, you get **overlapping** runs racing each other.
Prefer recursive setTimeout for async work
A self-scheduling `setTimeout` waits for the work to finish *before* scheduling the next run — no overlap, no drift accumulation:

JS
async 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

Text
   ┌──► 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:

JS
const t = setTimeout(() => console.log('maybe'), 60_000)
t.unref()   // if nothing else is pending, the process exits without waiting 60s
Promise-based timers

The timers/promises API gives you awaitable timers — perfect for a non-blocking "sleep" in async code, with built-in AbortSignal cancellation:

JS
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
A naive sleep blocks the loop — never do this
A busy-wait "sleep" — `while (Date.now() - start < ms) {}` — freezes the **entire** event loop: no I/O, no other timers, no requests served. Always yield with an awaitable timer instead.
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 setInterval for long async tasks → use recursive setTimeout instead.

  • A delay above ~24.8 days overflows the 32-bit field and fires immediately.

Next
See how timers relate to immediate callbacks and microtasks in [setImmediate & process.nextTick](/nodejs/set-immediate).