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
postMessageto 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
requestAnimationFramecallback 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
- HTML Living Standard — Timers — WHATWG
- Prioritized Task Scheduling — W3C Web Incubator Community Group
- setTimeout() — MDN Web Docs — MDN Web Docs