Timers in depth: setTimeout and setInterval
- Explain why a timer's delay is only a minimum: a busy thread, nested-timeout clamping, background-tab throttling, and the 2³¹−1 ms overflow that fires early
- Choose a recursive
setTimeoutoversetIntervalfor async or slow work, and compute clocks fromDate.now()instead of counting ticks - Write a polling loop that never overlaps, survives a failed poll and stops cleanly, even mid-request
- Type and call timers correctly in both hosts:
ReturnType<typeof setTimeout>, extra arguments,this, and Node'sunref()
Read
A timer's delay is a minimum, not an appointment
FlagsetTimeout(fn, 10) does not mean "run fn at 10 ms". It means "no sooner than 10 ms from
now, queue fn as a task". The task then waits its turn like any other: the thread has to be
free, and every pending microtask runs first (see The event loop).
const start = Date.now();
setTimeout(() => console.log("fired after", Date.now() - start, "ms"), 10);
while (Date.now() - start < 50) {} // 50 ms of synchronous work
// fired after 50-something ms, never 10
The timer expired at 10 ms, but the thread was busy until 50. Nothing can interrupt running code, so the callback runs as soon after 50 as it can, not at 10.
The HTML spec says it plainly: the API "does not guarantee that timers will run exactly on
schedule. Delays due to CPU load, other tasks, etc, are to be expected." Node's docs say the
same: the callback "will likely not be invoked in precisely delay milliseconds".
Two consequences you'll meet constantly:
- Never use a timer as a clock. A countdown that does
remaining -= 1000every tick drifts, because every tick is a little late. Store the deadline once and computedeadline - Date.now()on each tick. setTimeout(fn, 0)is "after the current work", not "now". It's a way to defer, and it's still a full task away.
And the receipt you get back is the handle: clearTimeout(handle) cancels a timer that hasn't
fired yet, and silently does nothing for one that has already fired or never existed. (Keeping
that handle across React renders is the Debounced input & autosave unit's topic.)
A poller that never overlaps, survives errors and stops cleanly
FlagA status poller as you'd ship it. It polls once straight away, then again intervalMs after
each poll finishes, and stop() works whatever the loop happens to be doing.
function pollStatus(onStatus, intervalMs = 5000) {
let stopped = false;
let timer; // the scheduled *next* poll, if there is one
async function tick() {
try {
const res = await fetch("/api/status");
if (!res.ok) throw new Error(`HTTP ${res.status}`);
onStatus(await res.json());
} catch (err) {
console.warn("status poll failed", err); // report it, keep polling
}
if (stopped) return; // stop() ran while we were awaiting
timer = setTimeout(tick, intervalMs); // only now schedule the next one
}
tick(); // first poll right away, not after 5 s
return function stop() {
stopped = true;
clearTimeout(timer);
};
}
Each line answers a specific failure:
tick()straight away.setIntervaland a firstsetTimeoutboth make the user wait a full interval for the first data.- Scheduling after the
await. The next poll can't start until this one has finished, so requests never overlap and never come back out of order. try/catcharound the work, with scheduling outside it. Without the catch, one failedfetchrejectstick()'s promise, which nobody handles: an unhandled rejection, and thesetTimeoutline is never reached. Polling stops forever and nothing tells you. Move the scheduling inside thetryand thecatchdoes report the error, but polling still stops.- Both a flag and
clearTimeoutinstop(). When you callstop(), the loop is in one of two states:- Waiting, with a timer pending.
clearTimeout(timer)cancels it. - Working, with a request in flight and no timer scheduled yet. There's nothing to clear, so without the flag the request finishes, schedules another tick, and the poller keeps going after you stopped it.
- Waiting, with a timer pending.
The handle and the flag cover different moments, and you need both. (clearTimeout on a timer
that already fired, or on undefined, does nothing, so calling it unconditionally is safe.)
Clamping, throttling, pausing and overflow
FlagBeyond a busy thread, four mechanisms make timers late, or wildly early.
1. Nested-timeout clamping (browsers). The HTML spec: "Timers can be nested; after five
such nested timers, however, the interval is forced to be at least four milliseconds."
Nesting counts any mix of setTimeout and setInterval scheduled from inside a timer
callback, and each repeat of a setInterval counts as one more level, so setInterval(fn, 0) is
clamped too. A recursive setTimeout(step, 0) runs its first few steps back to back, then
settles at about 4 ms each. A 1,000-step animation driven that way takes about 4 s. Node does
not apply this clamp. Its rule is that any delay below 1 (or NaN) becomes 1 ms.
2. Background-tab throttling (browsers, vendor-specific). Per MDN, Firefox desktop enforces at least 1 s in inactive tabs. Chrome checks timers about once a second in hidden pages, and since Chrome 88 "intensive throttling" checks chained timers only once a minute after a page has been hidden for 5 minutes and silent for 30 s. (Firefox for Android goes further: a 15-minute minimum.) A "session expires in 60 s" countdown in a background tab can fire a minute or more late.
3. Pausing (browsers). The spec only counts time while the document is fully active ("not necessarily consecutively"). A page in the back/forward cache isn't, so its timers stop counting and resume when the user navigates back.
4. Overflow, which fires early. The delay is stored as a signed 32-bit integer, so the maximum is 2,147,483,647 ms (about 24.8 days):
const THIRTY_DAYS = 30 * 24 * 60 * 60 * 1000; // 2,592,000,000
setTimeout(sendReminder, THIRTY_DAYS); // fires almost immediately
In browsers this value wraps to a negative number, which counts as 0 (MDN; much larger values
wrap to some other, shorter delay). In Node the delay is "set to 1" and a
TimeoutOverflowWarning is printed. Either way the reminder goes out now,
not in a month.
What to do instead
- Long waits: don't hold a timer for days. Store the deadline (in the database, or
localStorage) and check it on load, on an interval, or from a scheduled job on the server. - Anything that must be correct after a background period: recompute from the clock when the page becomes visible again:
document.addEventListener("visibilitychange", () => {
if (!document.hidden && Date.now() >= sessionExpiresAt) signOut();
});
- Smooth animation: use
requestAnimationFrame(it runs once per frame, and is paused in hidden tabs on purpose), and compute progress from its timestamp, not from a count of calls.
Quiz
setTimeout(cb, 10) is scheduled, then the same task does 50 ms of synchronous work. When does cb run?
In a browser, an animation advances one step per setTimeout(step, 0), with each step scheduling the next. The steps themselves take almost no time. Roughly how long do 1,000 steps take?
A dashboard polls with setInterval(async () => render(await fetchStatus()), 1000). The API slows to 3 s per response. What happens?
A checkout timer does secondsLeft -= 1 inside setInterval(…, 1000). After the user spends 10 minutes in another tab, it shows far too much time left. Best fix?
A recursive-setTimeout poller's stop() only calls clearTimeout(timer). The user clicks Stop while a request is in flight. What happens?
In a Next.js project (the DOM lib plus @types/node), const timer: number = setTimeout(save, 500) doesn't compile. Which fix is right in both the browser and Node?
A Node CLI finishes its work but never exits. The only thing still pending is setInterval(flushMetrics, 10_000). What's the smallest correct fix?
Fill in the blank
Finish the poller's loop: bail out if stop() ran during the await, then schedule the next poll only now that this one has finished.
const tick = async () => { try { await task(); } catch (error) { onError(error); } if () return; timer = (tick, intervalMs); };
Type the handle so the same file compiles whether setTimeout returns a number (browsers) or a Timeout object (Node).
let timer: <typeof setTimeout> | undefined;
Call counter.increment in one second with this still bound to counter, without writing an arrow function.
setTimeout(counter.increment.(counter), 1000);
The largest delay a timer honours, in milliseconds (about 24.8 days). Go past it and the delay overflows: a 30-day timer fires almost at once.
Flagconst MAX_DELAY = 2 ** - 1;
In Node, run a cache sweep every minute without the sweep alone keeping the process alive.
Flagconst sweep = setInterval(evictExpired, 60_000); sweep.();
Predict the output
const THIRTY_DAYS = 30 * 24 * 60 * 60 * 1000; // 2,592,000,000 ms
setTimeout(() => console.log("reminder sent"), THIRTY_DAYS);
setTimeout(() => console.log("100 ms passed"), 100);
console.log("scheduled");for (var i = 0; i < 3; i++) {
setTimeout(() => console.log("closure", i), 0);
}
for (var j = 0; j < 3; j++) {
setTimeout((n) => console.log("argument", n), 0, j);
}class Counter {
count = 0;
increment() {
this.count += 1;
}
}
const counter = new Counter();
setTimeout(counter.increment, 0);
setTimeout(() => counter.increment(), 0);
setTimeout(() => console.log(counter.count), 10);Challenge
Make the poller wait its turn, survive errors and stop cleanly
FlagstartPolling(task, { intervalMs, onError }) keeps a dashboard fresh: it calls task (an async
function that fetches and renders), waits, and calls it again until the caller runs the
stop() function it returned. config.ts sets the interval the tests use.
It's built on setInterval, and it has the three bugs that version always has in production:
- When the server is slower than the interval, requests overlap. Load piles onto a struggling server, and responses can land out of order.
- The first data appears only after a whole interval.
- One failed request ends polling for good, and nothing on screen says so.
Rebuild it as a recursive setTimeout. Two further tests guard stop(), which the naive
rewrite usually breaks: once you schedule the next poll after await task(), there are moments
when no timer exists yet.
Your task
Edit poll.ts so the tests pass:
- The first poll runs immediately, not after
intervalMs - The next poll starts
intervalMsafter the previous one finishes, so two polls never run at once - A failed poll is passed to
onError, and polling carries on stop()during an in-flight poll means no further poll ever startsstop()between polls cancels the scheduled one