Ch. 6 · Node.js

Node.js Timers and Scheduling Guarantees

setTimeout promises a minimum delay, not a time. Measured drift, skipped interval ticks, and how to build a countdown that stays correct.

~7 min readintermediateupdated Oct 7, 2026

setTimeout(fn, 1000) reads like “run fn in one second”. What Node actually promises is weaker: fn won’t run before one second has passed. After that, it runs when the event loop next reaches the timers phase and nothing else is in the way. If your code is busy for 300 ms at the moment the timer becomes due, the callback runs 300 ms late, and Node won’t tell you.

That gap between “eligible” and “executed” is behind countdowns that show the wrong number, rate limiters that let bursts through and polling loops that slowly fall behind. In interviews it shows up as “build a countdown timer” or “why is this interval inaccurate?”, and the expected answer is about clocks, not about timers.

What a stall does to setInterval

An interval every 100 ms. On the second tick, the callback does 350 ms of synchronous work, standing in for parsing a big JSON payload or a slow regex:

import { performance } from 'node:perf_hooks';

const t0 = performance.now();
let ticks = 0;
const iv = setInterval(() => {
  ticks++;
  console.log(`tick ${ticks} at ${Math.round(performance.now() - t0)} ms`);
  if (ticks === 2) { const end = performance.now() + 350; while (performance.now() < end); }
  if (ticks === 6) clearInterval(iv);
}, 100);
JavaScript

On Node 22:

tick 1 at 101 ms
tick 2 at 202 ms
tick 3 at 553 ms
tick 4 at 654 ms
tick 5 at 755 ms
tick 6 at 856 ms
Text

Ticks at 300, 400 and 500 ms never happened. Node didn’t queue them up and fire three in a row once the loop was free; it ran one tick as soon as it could (553 ms) and then rescheduled 100 ms from there. That’s usually what you want for a periodic job, but it means anything that counts ticks to measure time is wrong. Six ticks “means” 600 ms; the real elapsed time was 856.

A related misconception: people expect a timer to interrupt slow code. JavaScript in Node runs on one thread, and a timer callback can only start when the current callback returns. A timer can’t stop a runaway loop; that needs a worker thread or a separate process. The event loop phases note shows where timers sit relative to I/O callbacks and setImmediate.

A countdown that lies, and one that doesn’t

Here’s the interview question in its usual form: show a five-second countdown, updated every second. The naive version decrements a counter in each callback. The correct version computes a fixed deadline once and reads the clock on each tick:

import { performance } from 'node:perf_hooks';

const DURATION_MS = 5000;
const t0 = performance.now();
const deadline = t0 + DURATION_MS;
let naiveRemaining = DURATION_MS / 1000;
let ticks = 0;

const iv = setInterval(() => {
  ticks++;
  naiveRemaining -= 1;                                            // counts callbacks
  const remainingMs = Math.max(0, deadline - performance.now());  // reads the clock
  const real = ((performance.now() - t0) / 1000).toFixed(2);
  console.log(`t=${real}s  naive: ${naiveRemaining}s left   deadline: ${Math.ceil(remainingMs / 1000)}s left`);
  if (ticks === 1) { const end = performance.now() + 2500; while (performance.now() < end); } // a 2.5 s stall
  if (remainingMs === 0) { clearInterval(iv); console.log(`expired at t=${real}s (naive counter still says ${naiveRemaining}s)`); }
}, 1000);
JavaScript
t=1.00s  naive: 4s left   deadline: 4s left
t=3.51s  naive: 3s left   deadline: 2s left
t=4.51s  naive: 2s left   deadline: 1s left
t=5.51s  naive: 1s left   deadline: 0s left
expired at t=5.51s (naive counter still says 1s)
Text

After the 2.5-second stall, the naive countdown is a full second behind reality, and it stays behind for good. If it controlled anything real, such as an OTP expiry or a reservation hold, the user would see time left on something that had already expired. The deadline version corrects itself on the very next tick because it never trusted the ticks in the first place; they’re only a prompt to redraw.

Notice two smaller decisions. Math.max(0, ...) clamps the result, so a late final tick shows 0 rather than a negative number. And Math.ceil decides what “2 seconds left” means when 1.49 seconds remain; it’s the convention most countdown UIs use, so the display reaches 0 only when time is actually up.

The deadline version is still a little late: it noticed expiry at 5.51 s, because after the stall the interval’s phase shifted by half a second. If that matters, schedule the next update for the moment the displayed number should change, setTimeout(render, remainingMs % 1000 || 1000), instead of using a fixed interval.

Drift in chained timeouts

Polling loops often use a chained setTimeout so that one slow poll can’t overlap the next. Each step waits for its delay after the previous step’s work finished, so error accumulates. Fifty steps of 20 ms, each doing 3 ms of work:

function step() {
  n++;
  const end = performance.now() + 3; while (performance.now() < end); // 3 ms of work
  if (n === 50) return report();
  setTimeout(step, 20);
}
JavaScript
50 chained 20 ms timeouts: expected 1000 ms, took 1200 ms
Text

Every iteration adds the 3 ms of work plus about 1 ms of scheduling lag, so 50 iterations run 20% over. A “once a minute” job written this way runs fewer times per hour than planned, short by however much its work and lag add up.

The fix is to schedule each step against the plan rather than against the last run:

const next = t0 + (n + 1) * PERIOD;              // where this step should start
setTimeout(step, Math.max(0, next - performance.now()));
JavaScript
self-correcting: expected 1000 ms, took 1004 ms
Text

Individual steps still run a millisecond or two late, but the error doesn’t add up. If a step falls badly behind (a stall longer than the period), Math.max(0, ...) makes the next one run immediately; you then decide whether to run the missed steps or skip ahead, and for most jobs skipping is safer than a burst.

Which clock to read

The countdown used performance.now() on purpose. Node gives you three clocks:

Date.now()          1791424687526
performance.now()   26.025 (ms since process start)
process.hrtime.bigint() 307833171263541n
Text

Date.now() is wall-clock time in milliseconds since 1970. It can jump: NTP corrections, a manual clock change, or a VM resumed from a snapshot can move it backward or forward. A duration computed as the difference of two Date.now() values can come out negative. performance.now() and process.hrtime.bigint() are monotonic: they only move forward, at a steady rate, and they’re the right choice for elapsed time, timeouts and benchmarks. performance.now() is a millisecond float relative to performance.timeOrigin; hrtime.bigint() is nanoseconds as a BigInt.

Use Date.now() when the time itself means something outside the process: an expiry stored in a database, a timestamp in a log, a token’s exp claim. That’s the “calendar” use, where you want the wall clock even if it was just corrected.

Edge cases interviewers like

  • There’s no zero delay. setTimeout(fn, 0) is treated as 1 ms. It measured 1.243 ms here. Use setImmediate or queueMicrotask if you mean “after the current work”.
  • Huge delays overflow. Delays are stored as 32-bit signed integers, so anything above 2,147,483,647 ms (about 24.8 days) is replaced with 1 ms. setTimeout(fn, 2 ** 31) fired after 2 ms, with TimeoutOverflowWarning: 2147483648 does not fit into a 32-bit signed integer. Long schedules belong in a job scheduler, or need chaining shorter timeouts.
  • Timers keep the process alive. An active timer holds the event loop open. Calling timer.unref() lets the process exit if that timer is the only thing left, which is what you want for a periodic metrics flush.
  • Measuring lag. perf_hooks.monitorEventLoopDelay() reports how late timers are actually running, as a histogram. A rising p99 there is the early warning for every symptom above.

Interview practice

A session service gives each user 15 minutes of inactivity before logging them out. It’s implemented as let minutesLeft = 15; setInterval(() => { minutesLeft--; if (minutesLeft === 0) logout(); }, 60_000), reset on each request. Users on a busy instance report being logged out late, and the support team noticed that after the server’s clock was corrected by NTP, nothing changed. Explain both observations and propose a design.

Answer

Late logouts come from the tick counting. Each interval callback can run late when the event loop is busy, and the interval doesn’t catch up on missed time, so 15 ticks take more than 15 minutes. On a busy instance the error is large enough to notice. The NTP observation fits too: the code never reads a clock, so a corrected wall clock has no effect at all. That’s not a sign of correctness, just of not measuring time.

I’d store an expiry instead of a counter: on each request, set expiresAt = Date.now() + 15 * 60_000 and keep it with the session. Wall-clock time is right here because the expiry belongs to the session record, which may be stored in Redis and read by other instances. Every request checks Date.now() >= expiresAt before doing anything, so correctness doesn’t depend on a timer firing on time. A periodic sweep (or Redis key expiry) can clean up expired sessions, and if its timer is late, the only cost is slightly delayed cleanup, not a session that’s honoured after it expired. Within one process, for something like a request deadline, I’d use performance.now() instead, because it can’t jump.

Further reading

More in Node.js

read ✓Node.js · easy

Node.js Blocking Work and Request Latency

Why a 170 ms synchronous loop in one Node.js request delays every other request, how to measure loop delay, and when to chunk or use workers.

~6 min readread →
read ✓Node.js · hard

Node.js Caching Layers and Invalidation

Pick a cache layer in Node.js, choose TTLs per data type, keep failures out of the cache and stop a cold-cache stampede with one loader.

~7 min readread →
read ✓Node.js · hard

Node.js Clustering Across CPU Cores

Fork one Node.js worker per core with cluster, replace workers that crash, and plan for the state that each process keeps to itself.

~7 min readread →
esc