awesome-everything RU
↑ Back to the climb

Browser & Frontend Runtime

Timer accuracy, throttling, and idle work

Crux Why setTimeout(fn, 100) rarely fires at 100 ms — three throttling layers, background-tab coalescing, requestIdleCallback, and Promise combinator semantics.
Your altitude — climbing toward senior
ZeroJuniorMiddleSenior
You are at middle altitude — in the sky
◷ 14 min

Your polling loop drifts. A background tab’s setInterval fires once a minute instead of every second. A diagnostic timer meant to measure 50 ms measures 120 ms. Timers in the browser are a promise with asterisks, and the asterisks multiply the moment something else is busy.

Why setTimeout is a lower bound, not a guarantee

A setTimeout(fn, 100) does not promise to run fn in exactly 100 ms — it promises to queue the task no sooner than 100 ms. The actual run time depends on when the main thread is next free. If a 300 ms task is in flight when the timer fires, your callback waits the full 300 ms plus its queue position.

Three throttling layers compound this:

Layer 1 — the 4 ms nesting clamp. After 5 levels of setTimeout nesting (or setInterval), browsers clamp the delay to a minimum of 4 ms. This is a spec rule, not a quirk: it prevents setTimeout(0) chains from busy-looping the main thread. A 100-iteration loop using setTimeout(0) takes at least 400 ms regardless of how fast the work is.

Layer 2 — background-tab throttling. When a tab is hidden (document.visibilityState === 'hidden'), browsers clamp timers to a 1-second minimum. After the tab has been hidden for several minutes, Chrome further reduces to once per minute. Your background polling loop quietly slows to a crawl.

Layer 3 — timer alignment / coalescing. To save battery, browsers round timer fire times to a shared grid so the CPU can wake once for many timers instead of many times. The practical consequence: never use setTimeout/setInterval for anything needing sub-frame precision. For animation use requestAnimationFrame; for precise scheduling use the AudioContext clock or performance.now() deltas inside a rAF loop.

Three throttling layers
Normal minimum delay
0 ms (but queue-position-dependent)
Nesting clamp (depth ≥5)
4 ms minimum
Background tab
≥1 000 ms
Background tab (many minutes)
≥60 000 ms
Battery-saving coalescing
grid alignment, ~1 ms jitter
Quiz

A page calls `setTimeout(loop, 0)` where `loop` re-schedules itself. After 5 nesting levels, what happens to the effective delay?

Page visibility and the event loop lifecycle

The event loop does not stop when a tab goes to the background, but it changes mode. document.visibilityState switches to hidden, requestAnimationFrame stops firing entirely (no drawing = no rAF), and timers throttle as above.

The visibilitychange event is the right place to stop polling, pause video, flush unsaved state. The modern replacement for the unreliable unload event is pagehide plus the Page Lifecycle API with frozen and terminated states: the browser can fully freeze a background tab (stopping its loop entirely) to free memory, then thaw it on return. Code holding open connections or timers must listen to freeze/resume, or after thaw it is running with stale state — a closed WebSocket, an expired token, a detached observer.

Quiz

A background tab runs a 1-second polling setInterval. After 5 minutes in the background, how often does Chrome typically fire the callback?

requestIdleCallback — scheduling non-urgent work

Not all work is urgent. Analytics beacons, prefetching, cache warming, and log flushing can wait until the loop has nothing better to do. requestIdleCallback(fn) queues a callback that the browser runs only when an iteration finishes with spare time before the next frame deadline. The callback receives a deadline object whose timeRemaining() tells you how many ms you may safely use (capped around 50 ms).

The discipline: do a small slice, check timeRemaining(), and if it is near zero, re-schedule the rest with another requestIdleCallback. The optional { timeout } parameter forces the callback to run anyway after the deadline so the work is not starved forever.

requestIdleCallback is the cooperative opposite of requestAnimationFrame: rAF says “run me right before the next paint,” ridle says “run me only if nobody else needs the thread.” Used together they let you keep urgent work on rAF and defer everything else off the critical path.

Compute it

requestIdleCallback's `deadline.timeRemaining()` is capped at roughly this many milliseconds — the same as the long-task threshold.

ms

Promise combinators and scheduling

Promise.all, Promise.allSettled, Promise.race, and Promise.any change what you wait for, not how the loop schedules. All four start every passed promise immediately — the parallelism comes from kicking off async operations before awaiting.

  • Promise.all — rejects on the first failure (fail-fast). Right when any failure makes the whole result useless.
  • Promise.allSettled — never rejects; resolves with a status array. Right when you want partial results (five independent dashboard widgets where one failing should not blank the others).
  • Promise.race — settles on the first to settle, success or failure. Classic use: timeout: Promise.race([fetchData(), rejectAfter(5000)]).
  • Promise.any — resolves on the first success, ignoring rejections until all fail. Right for redundancy, like racing three CDN mirrors.

Picking the wrong combinator is a correctness bug, not a perf bug: Promise.all for a dashboard means one slow widget’s failure blanks the page.

A common bug worth naming: writing const a = await fetchA(); const b = await fetchB(); when both fetches are independent. The two requests run sequentially because the second await does not start until the first resolves. const [a, b] = await Promise.all([fetchA(), fetchB()]) kicks off both immediately. The cost difference is one full RTT. The clue in DevTools is two network requests with non-overlapping timing where both could have run in parallel.

Quiz

You have five independent API calls for a dashboard. One of them may fail. Which combinator fits?

Quiz

A background tab's `visibilitychange` event fires. The code holds an open WebSocket and a `setInterval` polling loop. What should it do?

Recall before you leave
  1. 01
    Name the three throttling layers that compound on setTimeout accuracy.
  2. 02
    When should you use requestIdleCallback instead of setTimeout(fn, 0)?
  3. 03
    Why is `const a = await fetchA(); const b = await fetchB();` a bug when both fetches are independent?
Recap

setTimeout(fn, delay) queues the task no sooner than delay milliseconds, but the actual fire time depends on main-thread availability and three throttling layers: the 4 ms nesting clamp (prevents setTimeout(0) busy-loops), background-tab throttling (1 s → 1 min), and timer coalescing for battery savings. For precise timing in animations, use requestAnimationFrame or the AudioContext clock — both are driven by the rendering pipeline, not the timer queue. For non-urgent work, requestIdleCallback runs only during genuine idle gaps and gives you a timeRemaining() budget to slice work safely. Promise combinators do not change loop scheduling but do change correctness: Promise.all for fail-fast, Promise.allSettled for partial results, Promise.race for timeouts, Promise.any for first-success redundancy.

Connected lessons
appears again in193
Continue the climb ↑Microtask starvation, Long Tasks, and LoAF
shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.