A setInterval scheduled for every 200 milliseconds can appear to work normally until you switch tabs. Leave the page hidden for several minutes, return, and the gaps between callbacks may be measured in seconds or even minutes. Nothing in setInterval(callback, 200) promises 200-millisecond execution intervals.

The browser is allowed to delay timer callbacks. It needs to make room for other tasks and, in background pages, reduce unnecessary CPU wake-ups and battery consumption. The important engineering question is not how to force a timer to run on time. It is whether your application depends on a promise the timer API never made.

This guide covers window timers (setTimeout and setInterval) in browser pages, especially Chrome's documented background policy. It is not a description of Node.js timers, and Chrome's thresholds should not be treated as universal browser rules.

A timer is an earliest opportunity, not a deadline

Calling setTimeout(fn, 100) makes fn eligible to run after a delay; it does not interrupt JavaScript that is already running. The browser still has to schedule the timer's task. A busy main thread, another long task, or a browser scheduling policy can make the observed delay longer.

There is also a separate nested-timer minimum. Under the HTML Standard's timer algorithm, after the relevant nesting level exceeds the threshold, requested delays below 4 milliseconds are clamped to 4 milliseconds. This affects chains of short timers, including calls from another timer callback. It is not a 4-millisecond guarantee that the callback will actually run then.

A useful distinction:

SymptomLikely explanation to investigate
A setTimeout(fn, 0) callback runs after synchronous codeA timer schedules later work; it does not run inline
A long chain of near-zero timers stops running near zeroThe 4 ms nested-timer minimum
A 200 ms interval stretches after switching tabsBackground timer throttling or suspension
Timers slip while the page remains visibleMain-thread work, scheduling contention, or other browser activity

For task and microtask ordering, see JavaScript Event Loop Explained. Timer throttling adds a scheduling constraint to that model; it does not change promises into timer tasks or reorder synchronous statements.

What changes when the tab is hidden?

Chrome documents three timer scheduling regimes. The following is Chrome's documented behavior introduced with Chrome 88, not a cross-browser specification or a guarantee that every callback arrives at the stated frequency.

Chrome regimeConditions, simplifiedHow Chrome checks timers
Minimal throttlingPage is visible or made audible sound in the last 30 secondsClose to requested delay, subject to the nested 4 ms minimum and ordinary scheduling
ThrottlingMinimal conditions do not apply, and the chain is short, the page has been hidden less than 5 minutes, or qualifying WebRTC is activeApproximately once per second, in batches
Intensive throttlingPage hidden more than 5 minutes, timer chain at least 5, silent for at least 30 seconds, and no qualifying WebRTCApproximately once per minute, in batches

A repeated setInterval counts as a chained timer. A setTimeout scheduled in the same task as a previous timer callback can also form a chain. That distinction explains why a repeating background poll is especially likely to encounter intensive throttling. An unrelated one-shot timeout does not automatically meet the chain condition.

The thresholds and exceptions come from Chrome's timer-throttling explanation. There are additional scheduling constraints, including background execution budgets, and the browser can delay work further. Playing silent audio is not a legitimate way to claim an audible-page exemption.

Other browsers make different decisions. For example, MDN documents different inactive-tab timeout policies for Firefox desktop and Firefox for Android. Mobile browsers may also suspend or unload a background page. Avoid writing application logic around a particular one-second or one-minute interval.

Measure the delay instead of guessing

The following browser-console probe records the actual gap between consecutive interval callbacks. It stores the latest 1,000 samples so the log itself does not become the workload being measured.

(() => {
  const requestedMs = 200;
  const samples = [];
  const transitions = [];
  const start = performance.now();
  let previous = start;

  const timerId = setInterval(() => {
    const now = performance.now();
    samples.push({
      elapsedSinceStartMs: Math.round(now - start),
      gapMs: Math.round(now - previous),
      hiddenAtCallback: document.hidden,
    });
    if (samples.length > 1000) samples.shift();
    previous = now;
  }, requestedMs);

  const onVisibilityChange = () => {
    transitions.push({
      elapsedSinceStartMs: Math.round(performance.now() - start),
      state: document.visibilityState,
    });
  };

  document.addEventListener('visibilitychange', onVisibilityChange);
  window.stopTimerProbe = () => {
    clearInterval(timerId);
    document.removeEventListener('visibilitychange', onVisibilityChange);
    console.table(transitions);
    console.table(samples.slice(-20));
    delete window.stopTimerProbe;
    return { samples, transitions, requestedMs };
  };
})();
  1. Open an ordinary browser tab, paste the snippet into its DevTools Console, and keep it visible briefly.
  2. Switch to a different tab for at least a minute. For Chrome's intensive-throttling case, leave it hidden longer than five minutes and allow extra time for a minute-batched callback to occur.
  3. Return and run stopTimerProbe(). Compare gapMs with the requested 200 ms, and use the visibility transitions to place the larger gaps in context.
  4. Repeat in a second browser if you need cross-browser evidence. Record the browser version, operating system, tab state, audio/WebRTC activity, and whether DevTools was attached.

hiddenAtCallback is the state when the callback finally ran, not proof of where the callback spent its entire waiting time. A long gap can cross the transition back to visibility. DevTools, power settings, browser flags, and page suspension can also influence results. This is a reproducible measurement method, not a claim that a specific delay was observed in a production application.

If the page stays visible but the gaps are large, record a Performance trace and look for long JavaScript tasks. The guide to Chrome DevTools Network and Performance tabs provides an introduction to those tools. A delayed timer alone cannot distinguish a busy main thread from background throttling.

Fix the assumption, not the throttle

Different uses of timers need different responses.

A countdown must use elapsed time

This approach drifts:

let remainingSeconds = 60;
setInterval(() => {
  remainingSeconds -= 1; // Assumes one callback equals one elapsed second.
}, 1000);

After the tab has been hidden, the number of callbacks may have little relationship to actual elapsed time. Store a target time and calculate what remains whenever the UI is updated:

const deadline = Date.now() + 60_000;
const output = document.querySelector('#remaining');
let timerId;

function renderCountdown() {
  clearTimeout(timerId);
  const remainingMs = Math.max(0, deadline - Date.now());
  output.textContent = String(Math.ceil(remainingMs / 1000));

  if (remainingMs > 0 && !document.hidden) {
    timerId = setTimeout(renderCountdown, 250);
  }
}

document.addEventListener('visibilitychange', renderCountdown);
renderCountdown();

The example assumes an element with id="remaining" exists. The callback can be late, but it does not subtract imaginary one-second ticks. Date.now() follows the device's wall clock and can jump if the clock changes. For security-sensitive deadlines, payments, or expirations, the server must remain authoritative; a client-side countdown is only a display.

Poll when useful, and refresh on return

For a dashboard that only needs fresh status while it is visible, pausing background polling avoids wasted requests. Refreshing immediately on return addresses stale data. Scheduling the next timeout after the request settles also prevents overlapping interval-driven requests.

This illustrative browser example assumes an endpoint /api/status that returns JSON:

const stopStatusPolling = (() => {
  let timerId;
  let inFlight = false;
  let refreshQueued = false;
  let stopped = false;

  async function refresh() {
    if (stopped || document.hidden) return;
    clearTimeout(timerId);

    if (inFlight) {
      refreshQueued = true;
      return;
    }

    inFlight = true;
    try {
      const response = await fetch('/api/status', { cache: 'no-store' });
      if (!response.ok) throw new Error(`HTTP ${response.status}`);
      const data = await response.json();
      if (!stopped && !document.hidden) console.log('status:', data);
    } catch (error) {
      if (!stopped) console.error('Status refresh failed:', error);
    } finally {
      inFlight = false;
      if (stopped || document.hidden) return;

      if (refreshQueued) {
        refreshQueued = false;
        void refresh();
      } else {
        timerId = setTimeout(refresh, 15_000);
      }
    }
  }

  function onVisibilityChange() {
    clearTimeout(timerId);
    if (document.hidden) {
      refreshQueued = false;
      return;
    }
    void refresh();
  }

  document.addEventListener('visibilitychange', onVisibilityChange);
  void refresh();

  return () => {
    stopped = true;
    clearTimeout(timerId);
    document.removeEventListener('visibilitychange', onVisibilityChange);
  };
})();

Call stopStatusPolling() when the component or page feature no longer needs the subscription. For React, place setup and cleanup in an effect with the appropriate dependencies rather than repeatedly creating a poller during rendering. An in-flight request is not canceled by this example; add an AbortController if your application needs cancellation. For near-real-time server updates, consider server-sent events or WebSockets instead of repeated polling, with their own lifecycle and reconnection handling.

visibilitychange is the relevant signal for a hidden document. A window losing keyboard focus (blur) does not always mean the page is hidden. See the Page Visibility API.

For animation, use frame scheduling

Use requestAnimationFrame for animation updates, preferably calculating movement from its timestamp rather than assuming 60 callbacks per second. Browsers usually pause these callbacks in background tabs, and display refresh rates vary. MDN's requestAnimationFrame reference explains both behaviors. CSS animations or the Web Animations API may better express purely visual transitions.

A Web Worker can move expensive computation off the UI thread, but it is not a general guarantee of precise timers or unrestricted background execution. Likewise, moving a clock into another worker does not make browser scheduling limits disappear. For work that must happen while the user is absent, design around an appropriate server-side process or an explicitly supported background API rather than trusting a hidden tab to stay alive.

A practical diagnosis checklist

When an application seems to "lose time," check these in order:

  1. What does correctness depend on? Elapsed time, a fixed deadline, a fresh server state, or the number of callbacks? The last one is usually the wrong basis for correctness.
  2. Was the page hidden? Log document.visibilityState transitions and compare actual callback gaps with the requested interval.
  3. Was the main thread busy? Use a Performance recording to distinguish long tasks from scheduling decisions.
  4. Is the behavior browser-specific? Check Chrome's chain, audio, and WebRTC conditions, then repeat in the browsers that matter to your users.
  5. Can the timer be replaced? Recompute deadlines on return, refresh stale state, use browser events for change detection, or let the server own jobs that cannot depend on a visible page.

The robust mental model is simple: a timer asks for a future opportunity to run, not a precise execution appointment. Once correctness is based on real elapsed time and authoritative state, background throttling becomes something to accommodate rather than defeat.