Web Platform · JavaScript Runtime

Timer Clamping, Background Throttling, and Cooperative Yielding

The integer arithmetic and nesting rules behind setTimeout, why intervals do not catch up, and how scheduler priority levels change when a callback runs.

setTimeout(fn, 0) does not mean “run now”. It means “enqueue a task on the timer task source with zero delay”, and between that enqueue and the callback running, the loop must finish the current task, drain microtasks, and possibly produce a frame. The specification then adds a set of arithmetic and nesting rules on top, and browsers add throttling policies that are not in the specification at all. Together they make timers a coarse scheduling primitive whose actual resolution is far looser than its millisecond argument suggests.

From argument to deadline

The timer initialization steps convert the timeout argument to an integer, and the conversion is observable. A negative value becomes zero. A non-numeric value becomes zero. A value above 2,147,483,647 milliseconds — the largest signed 32-bit integer, about 24.8 days — is set to zero, not clamped to the maximum, which is the origin of the famous behaviour where a very large delay fires almost immediately. The conversion is Web IDL’s: the long parameter wraps modulo 2^32, so the value becomes negative, and the timer algorithm then clamps a negative timeout to zero. Browsers have matched this for so long that the overflow is effectively part of the platform.

Once the timeout is an integer, the nesting level of the calling context is inspected. If the timer was scheduled from inside a timer callback and the nesting level exceeds five, the effective delay is at least 4 milliseconds. The nesting level is a property of the task, not of the page: a timer scheduled from a message event starts counting from zero even when the message was posted from inside a timer chain, and a timer scheduled in response to user input likewise starts fresh. That is the technical reason the MessageChannel trick works as a faster substitute for nested setTimeout — it avoids the floor by changing the task source, not by making the browser faster.

The delay is a lower bound on the interval before the task is queued, not before it runs. A timer whose deadline has passed does not preempt the current task. If the main thread is executing a long script, the timer waits for it, and the lateness is unbounded. Browsers also coarsen timer deadlines for power management, so a 1 ms delay may be rounded to a hardware timer tick; the practical floor on a desktop is a small number of milliseconds, and on battery-saving configurations it is larger.

Intervals do not accumulate credit

setInterval repeats, but it does not queue one task per missed period. If the callback takes longer than the interval, or the thread is blocked across several periods, the implementation does not fire a burst of catch-up callbacks; at most one task for that interval is pending at a time. The observable consequence is drift: an interval of 1000 ms whose callback takes 300 ms will fire roughly every 1300 ms, not every 1000 ms with occasional skips.

let expected = performance.now();
const id = setInterval(() => {
  const now = performance.now();
  const drift = now - expected;
  expected += 1000;
  // `drift` grows whenever a callback or a blocking task overruns the period
  if (drift > 5000) clearInterval(id);   // give up rather than compound
}, 1000);

Time-driven work should compute its state from a clock, not from a count of ticks. A clock display that increments a counter once per interval will be wrong after a suspension; one that reads the current time and renders the difference is self-correcting. The same reasoning applies to animation, where the timestamp from requestAnimationFrame is the frame clock, and to any retry loop with a deadline.

Throttling that the specification does not describe

A hidden page has no rendering opportunities, and browsers reduce timer frequency for pages that are not visible. The common policy is to run timers at most once per second in a background tab, then, after several minutes of background residence, to reduce further to roughly once per minute for pages that are not playing audio and have no active connection. The exact thresholds and rates differ between browsers and change between versions; what is stable is the direction and the fact that the reduction is not announced to the page.

document.visibilityState and the visibilitychange event are the supported way to react. The event fires when the page becomes hidden or visible, and it is delivered before the page is frozen, so persisting state on hide is reliable. Long-running periodic work should be paused on hide and resumed on show rather than relying on the timer to keep a steady cadence; a throttled interval fires at an unpredictable rate, so work keyed to an assumed cadence does the wrong amount of work at the wrong times.

Timers are also suspended entirely in a page that has entered the back/forward cache. A page restored from that cache resumes with its JavaScript heap intact and its timers restarted, but wall-clock time has passed. Any code that computed an expiry by counting intervals is wrong on restoration; code that compares against Date.now() is not.

Priority levels move the task, not the thread

scheduler.postTask(callback, { priority }) enqueues the callback on a priority-ordered queue managed by the scheduler, with three levels: user-blocking, user-visible, and background. The default is user-visible. The value of the API is that it separates the callback from the timer source and its clamping, and gives the browser an explicit statement of intent that it can use when choosing among runnable tasks. It returns a promise, supports an AbortSignal for cancellation, and reports the callback’s return value or thrown error through that promise.

The priority does not create parallelism and does not preempt the running task. A user-blocking task still waits for the current task to finish and for its microtasks to drain. What it changes is the order in which the loop selects among ready tasks, and, in implementations that support it, whether the browser is willing to run the task before the next rendering opportunity. Setting every task to user-blocking therefore flattens the distinction and is equivalent to setting none of them.

Where the API is unavailable, the fallbacks differ in more than syntax. A MessageChannel yields quickly but at default priority, cannot be cancelled once posted, and cannot carry a result. A zero-delay setTimeout is subject to the nesting floor and background throttling. A microtask yields not at all — it runs before the loop can do anything else, so it cannot be used to break up work. Choosing between them is choosing which of those limitations is acceptable.

Starvation, from the other side

The starvation described in the task-source model is usually discussed as a lost frame, but it is better understood as a queue that never empties. Three patterns produce it:

  • Self-rescheduling tasks. A postMessage to the same port from inside its own handler keeps the posted-message source permanently ready. Browsers interleave sources to avoid total starvation, but the effect is latency inflation for everything else.
  • Chained microtasks. A promise reaction that schedules another reaction never lets the checkpoint finish. Rendering and input stop; the page appears hung while the stack remains shallow.
  • Synchronous work in a frame callback. A requestAnimationFrame callback that does more than the frame budget allows does not merely miss one frame; it delays the next rendering opportunity, because the loop cannot begin the next frame until the current task returns.

The remedy in all three cases is the same shape: bound the work per turn, and yield to a source that the loop is allowed to skip. Chunking a large computation means processing a slice, then scheduling the next slice as a task — scheduler.postTask where available, a message task otherwise — and checking elapsed time against a budget rather than a fixed iteration count. Fixed counts are a guess about machine speed; elapsed time is a measurement, and the only honest basis for a decision about whether to keep going.

What timers are actually good for

After all the clamping and throttling, timers remain the right tool for work that must happen after a specified wall-clock interval and does not need precision: retry backoff, polling with a coarse cadence, debouncing keystrokes, expiring a cached value. They are the wrong tool for animation, for ordering against rendering, and for splitting a computation into small pieces, all of which have dedicated mechanisms. A timer’s argument names a minimum delay, and the platform is careful never to promise more than that.

References

  1. HTML Living Standard — Timers — WHATWG
  2. Prioritized Task Scheduling — W3C Web Incubator Community Group
  3. setTimeout() — MDN Web Docs — MDN Web Docs