Async JavaScript & Timing
Async JavaScript & Timing

Timers in depth: setTimeout and setInterval

Full · ~35 min
After this unit you can
  • 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 setTimeout over setInterval for async or slow work, and compute clocks from Date.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's unref()

Read

Simple
1 / 2

A timer's delay is a minimum, not an appointment

Flag

setTimeout(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 -= 1000 every tick drifts, because every tick is a little late. Store the deadline once and compute deadline - 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.)

Medium
1 / 2

A poller that never overlaps, survives errors and stops cleanly

Flag

A 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. setInterval and a first setTimeout both 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/catch around the work, with scheduling outside it. Without the catch, one failed fetch rejects tick()'s promise, which nobody handles: an unhandled rejection, and the setTimeout line is never reached. Polling stops forever and nothing tells you. Move the scheduling inside the try and the catch does report the error, but polling still stops.
  • Both a flag and clearTimeout in stop(). When you call stop(), 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.

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

Complex
1 / 2

Clamping, throttling, pausing and overflow

Flag

Beyond 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?

Flag

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?

Flag

A dashboard polls with setInterval(async () => render(await fetchStatus()), 1000). The API slows to 3 s per response. What happens?

Flag

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?

Flag

A recursive-setTimeout poller's stop() only calls clearTimeout(timer). The user clicks Stop while a request is in flight. What happens?

Flag

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?

Flag

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?

Flag

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.

Flag
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).

Flag
let timer: <typeof setTimeout> | undefined;

Call counter.increment in one second with this still bound to counter, without writing an arrow function.

Flag
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.

Flag
const MAX_DELAY = 2 **  - 1;

In Node, run a cache sweep every minute without the sweep alone keeping the process alive.

Flag
const 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");
Flag
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);
}
Flag
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);
Flag

Challenge

Make the poller wait its turn, survive errors and stop cleanly

Flag

startPolling(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 intervalMs after 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 starts
  • stop() between polls cancels the scheduled one

Loading the in-browser editor…

Further reading