Browser timers drift because setInterval() and setTimeout() guarantee only a minimum delay, never an exact one—the single-threaded event loop, background-tab throttling, and a built-in 4 ms clamp each push ticks late, and those errors accumulate over hundreds of ticks. The fix replaces tick-counting with timestamp comparison: you store a target end time once, then recompute the remaining time against the real clock on every frame so the error never compounds.

Why setInterval() and setTimeout() Drift

Both functions drift because their delay argument sets a floor, not a deadline. The browser promises to wait at least that long before queuing your callback, then runs the callback whenever the main thread next falls idle. MDN states plainly that “the actual amount of time that elapses between calls to the callback may be longer than the given delay.” Three mechanisms stretch that gap:

  • Event-loop contention queues your callback behind whatever code is already executing.
  • Background-tab throttling slows hidden tabs to a 1,000 ms floor or worse to save CPU and battery.
  • Nesting clamps force deeply chained timers up to a 4 ms minimum.

The event loop runs one task at a time

JavaScript executes on a single thread, so every timer callback waits in a queue behind code that is already running. A 1,000 ms interval lands at 1,040 ms when it fires during a 40 ms layout or parsing task—the timer fired on schedule, but the thread stayed busy. This ordering is guaranteed, not occasional: a setTimeout(foo, 0) call still logs after every synchronous line that follows it, because the zero-delay callback cannot jump ahead of work already in progress.

Background tabs throttle to 1,000 ms or slower

Hidden tabs lose timer precision deliberately. Firefox clamps inactive-tab timers to a 1,000 ms minimum, and Firefox for Android raises that floor to 15 minutes. Chrome applies intensive throttling that checks timers just once per minute once three conditions all hold: the page has been hidden for more than 5 minutes, the timer chain count is 5 or greater, and the page has stayed silent for at least 30 seconds. A timer that counts its own ticks therefore stops counting accurately the moment you switch tabs. Mobile browsers tighten these rules further, which our guide on running timers in background tabs on mobile covers in detail.

Deeply nested timers clamp to 4 ms

The HTML Standard sets a hard floor for chained timers: “If nesting level is greater than 5, and timeout is less than 4, then set timeout to 4.” A chain of setTimeout(fn, 0) calls runs at roughly 0 ms for the first four iterations, then snaps to a 4 ms minimum on the fifth and every call after it. Both setTimeout() and setInterval() increment the same nesting counter, so the clamp applies to mixed chains as well as pure ones.

How Drift Accumulates Across a Countdown

Drift compounds whenever code counts ticks instead of measuring time. A countdown that subtracts one second per setInterval(fn, 1000) tick assumes every tick equals exactly 1,000 ms. Real ticks land at 1,001–1,005 ms in an active tab, so a steady 3 ms lag across the 1,500 ticks of a 25-minute timer adds roughly 4.5 seconds of error by the end. Background the tab and the damage multiplies—throttled ticks fire seconds or minutes apart, so a tick-counting timer falls behind real time by nearly the entire span the tab spent hidden, and it never recovers the lost seconds on its own.

The Fix: Compare Against a Target End Time

Timestamp-based timing eliminates drift by recomputing from the system clock on every tick. You capture the finish moment once—endTime = Date.now() + duration—then on each update you derive the display from remaining = endTime − Date.now(). The interval that drives the loop no longer carries any timing responsibility; it exists only to schedule the next read of the clock. A late, early, or coalesced tick corrects itself instantly because the next read still measures true elapsed time. This single change converts an error that accumulates into one that self-heals:

  • No accumulation — each tick reads absolute time, so a slow tick never poisons the next one.
  • Throttle resistance — a tab that wakes after 10 minutes hidden shows the correct remaining time on its first frame back.
  • Clamp immunity — the 4 ms floor and 1,000 ms throttle change how often you repaint, not what time you report.

Date.now() versus performance.now()

The two clocks differ in what they track. Date.now() returns milliseconds since the Unix epoch and follows system-clock changes such as NTP syncs, manual edits, and daylight-saving shifts. performance.now() returns a monotonic high-resolution timestamp measured from page load that never jumps backward. For measuring elapsed time inside a single session, performance.now() is the steadier base; for counting down to a fixed wall-clock moment, Date.now() aligns with the real-world deadline. Both beat tick-counting, because both anchor each frame to a real clock instead of an assumed interval length.

Keep the Display Smooth Without Trusting It for Time

requestAnimationFrame() handles the visible refresh while the timestamp handles the truth. rAF fires in step with the display’s refresh cycle—about every 16.7 ms on a 60 Hz screen—and pauses automatically in hidden tabs, which keeps the countdown animation smooth and stops wasted background work. rAF never serves as the source of truth for elapsed time, because its cadence varies with the refresh rate and halts entirely when the tab is hidden. The reliable pattern pairs the two roles: requestAnimationFrame() decides when to repaint, and endTime − Date.now() decides what number to paint.

How TheBlogTimer Prevents Drift

Every timer on this site reads a stored end timestamp on each frame instead of counting intervals. The countdown captures its target time the instant you start it, recomputes the remaining time from the system clock on every animation frame, and survives tab switches, sleeping laptops, and background throttling without losing a second. The table below contrasts the two approaches for a 25-minute timer:

Approach What it tracks Drift over 25 min (active tab) Behavior when tab is hidden Source of truth
Counter (setInterval −1 s) Number of ticks Several seconds Falls behind by the hidden duration The timer’s own tick rate
Timestamp (endTime − Date.now()) Real elapsed time Under one display frame (~16.7 ms) Shows correct time on the first frame back The system clock

This is also why a stopwatch and a countdown built the same way report matching durations. If you want the measured numbers behind these claims, our timer accuracy guide documents how close each timer stays to a reference clock, and you can put the timestamp method to work in any countdown across our minute timers collection.

Sources

  1. MDN Web Docs (2025). setTimeout() global function
  2. MDN Web Docs (2025). setInterval() global function
  3. Chrome for Developers (2020). Heavy throttling of chained JS timers beginning in Chrome 88
  4. WHATWG (2026). HTML Standard: Timers and user prompts

Browse Related Guide Topics

Frequently Asked Questions

JavaScript timers drift because setInterval() and setTimeout() guarantee only a minimum delay, so event-loop contention, background-tab throttling, and a 4 ms nesting clamp push each tick late and the errors accumulate when code counts ticks instead of measuring real elapsed time.

setInterval stays accurate to within a few milliseconds in an active tab but degrades to a 1,000 ms floor in backgrounded Firefox tabs and as little as once per minute under Chrome's intensive throttling after a page has been hidden for five minutes.

You make a countdown accurate by storing a target end timestamp once and recomputing the remaining time as endTime minus Date.now() on every tick, so a late or coalesced tick corrects itself instead of compounding into visible drift.

Your timer falls behind in a background tab because browsers throttle hidden-tab timers to save CPU and battery, slowing Firefox to a 1,000 ms minimum and Chrome to once per minute, so any timer that counts its own ticks loses time it never recovers.

See Also