DEV Community

Cover image for The Background-Tab Bug I Missed in My JavaScript Polling Code
Alex
Alex

Posted on

The Background-Tab Bug I Missed in My JavaScript Polling Code

I thought replacing setInterval() with recursive setTimeout() had made a polling loop safe. Only one request would run at a time, and I cleared the next timeout when the page became hidden.

It still had a race.

The timeout was gone. The request already in flight was not.

If the page became visible again before that old request finished, a new polling run could start. The old response could then arrive last and overwrite fresher state. Nothing in clearTimeout() protected the UI from it.

That was more dangerous than the timer drift I had started investigating. A late counter is visible. A stale response can look completely normal.

Fixing it required three separate decisions:

  • timestamps measure elapsed time;
  • visibility changes trigger a resynchronisation;
  • cancellation and a run identifier prevent stale requests from updating state.

The browser is allowed to delay background work. The application still has to know which work is current.

First, prove that a timer is not a clock

Before changing the polling code, I wanted a small test that separated elapsed time from callbacks. It asks for an interval callback every second and displays four values:

  • elapsed wall time;
  • callbacks actually delivered;
  • the difference between those two counters;
  • the largest gap between callbacks.

I am deliberately not publishing one “background tabs delay callbacks by exactly N seconds” result. The policy depends on the browser, version, operating system, power state, audio, WebRTC, and resource pressure. A number from one machine would be easy to repeat and easy to misuse.

This is the smallest useful version of the test. Save it as timer-test.html and open it in a normal browser tab:

<!doctype html>
<html lang="en">
  <meta charset="utf-8" />
  <title>Background timer test</title>

  <button id="start">Start</button>
  <pre id="output">Press Start</pre>

  <script>
    const output = document.querySelector("#output");
    const startButton = document.querySelector("#start");

    let intervalId;
    let startedAt = 0;
    let lastCallbackAt = 0;
    let callbacks = 0;
    let largestGapMs = 0;

    function render() {
      const elapsedSeconds = Math.floor((Date.now() - startedAt) / 1000);
      const callbackLag = Math.max(0, elapsedSeconds - callbacks);

      output.textContent = [
        `visibility: ${document.visibilityState}`,
        `wall time: ${elapsedSeconds}s`,
        `callbacks: ${callbacks}`,
        `counter lag: ${callbackLag}s`,
        `largest callback gap: ${(largestGapMs / 1000).toFixed(2)}s`,
      ].join("\n");
    }

    startButton.addEventListener("click", () => {
      clearInterval(intervalId);

      startedAt = Date.now();
      lastCallbackAt = startedAt;
      callbacks = 0;
      largestGapMs = 0;
      render();

      intervalId = setInterval(() => {
        const now = Date.now();
        largestGapMs = Math.max(largestGapMs, now - lastCallbackAt);
        lastCallbackAt = now;
        callbacks += 1;
        render();
      }, 1000);
    });

    document.addEventListener("visibilitychange", () => {
      if (startedAt) render();
    });
  </script>
</html>
Enter fullscreen mode Exit fullscreen mode

Start it, keep the page visible for a few seconds, switch to another tab for several minutes, then return and wait for the next callback. Compare wall time with the callback count and largest gap.

Do not expect one universal number. If the counters barely diverge, that is still a valid result for that browser and that run. Record the browser version, hidden duration, power state, and whether the page was playing audio before comparing it with somebody else’s result.

An interval is a request, not a clock

This counter looks harmless:

let secondsSinceUpdate = 0;

setInterval(() => {
  secondsSinceUpdate += 1;
  renderLastUpdated(secondsSinceUpdate);
}, 1000);
Enter fullscreen mode Exit fullscreen mode

It does not measure time. It counts callbacks.

setInterval(callback, 1000) asks the browser not to run the callback before the delay has passed. It does not guarantee that the main thread will be available at that moment, or that a hidden page will keep receiving callbacks on the same schedule.

Browsers have good reasons for this. Background pages consume CPU and battery without producing anything the user can see. Chrome documents several levels of timer throttling, including more aggressive treatment of chained timers after a page has been hidden for a while. The exact conditions include visibility, recent audio, WebRTC use, the timer chain, and how long the page has been hidden.

That policy can change. Application correctness should not depend on reproducing one exact delay.

For a “last updated” label, the reliable source is the timestamp of the update:

const updatedAt = Date.now();

function renderAge() {
  const elapsedMs = Date.now() - updatedAt;
  renderLastUpdated(Math.floor(elapsedMs / 1000));
}

setInterval(renderAge, 1000);
Enter fullscreen mode Exit fullscreen mode

The interval still decides when the display gets another chance to repaint. It no longer decides how much time passed. When the callback finally runs, the value catches up immediately.

Date.now() is wall-clock time and can move if the system clock changes. That is appropriate when comparing an event timestamp with the current clock. For measuring a duration inside one active session, use the monotonic performance.now() instead.

Give the page a return path

The Page Visibility API exposes the transition we care about:

document.addEventListener("visibilitychange", () => {
  if (document.hidden) {
    pauseNonEssentialWork();
    return;
  }

  refreshVisibleState();
});
Enter fullscreen mode Exit fullscreen mode

I do not use this event to fight throttling. I use it to make returning predictable.

When the page becomes visible again, it should:

  • derive displayed time from timestamps;
  • fetch data that may have changed;
  • check whether real-time connections are still useful;
  • render from current state instead of replaying missed visual steps.

A background page is allowed to become stale. The bug is returning without noticing.

The race I nearly missed

After fixing the visible counter, I returned to the polling loop. Replacing setInterval() with recursive setTimeout() prevented overlapping schedules, and clearing the timeout prevented the next request. It still did not cancel the request already in flight.

There is an awkward sequence hidden inside that simpler code:

  1. a request starts;
  2. the page becomes hidden;
  3. polling is stopped, but the request continues;
  4. the page becomes visible and starts a new request;
  5. the old response arrives last and overwrites the newer state.

So the controller needs both cancellation and a run identifier:

let timerId;
let activeRequest;
let runId = 0;
let running = false;

async function poll(currentRun) {
  if (!running || document.hidden || currentRun !== runId) return;

  const request = new AbortController();
  activeRequest = request;

  try {
    const response = await fetch("/api/status", {
      signal: request.signal,
    });

    if (!response.ok) {
      throw new Error(`Status request failed: ${response.status}`);
    }

    const status = await response.json();

    if (running && currentRun === runId && !request.signal.aborted) {
      updateStatus(status);
    }
  } catch (error) {
    if (error?.name !== "AbortError" && currentRun === runId) {
      reportPollingError(error);
    }
  } finally {
    if (activeRequest === request) {
      activeRequest = undefined;
    }

    if (running && currentRun === runId && !document.hidden) {
      timerId = setTimeout(() => poll(currentRun), 5000);
    }
  }
}

function startPolling() {
  stopPolling();
  running = true;
  const currentRun = ++runId;
  void poll(currentRun); // refresh immediately
}

function stopPolling() {
  running = false;
  runId += 1;
  clearTimeout(timerId);
  activeRequest?.abort();
  activeRequest = undefined;
}

document.addEventListener("visibilitychange", () => {
  document.hidden ? stopPolling() : startPolling();
});

startPolling();
Enter fullscreen mode Exit fullscreen mode

This version still needs application-specific retry and backoff rules. The important properties are easier to state:

  • one active polling run;
  • one in-flight request;
  • stale results cannot update the page;
  • returning to the page triggers an immediate refresh.

In production I would test cancellation, stale-result suppression, and the hidden-page guard because this is exactly the kind of lifecycle code that looks correct until two events happen close together.

Animation frames should render state

Most browsers stop sending requestAnimationFrame() callbacks to hidden pages. That is useful: there is nothing to draw.

It becomes a problem only when frame count is also application state:

let progress = 0;

function frame() {
  progress += 0.1;
  drawProgress(progress);
  requestAnimationFrame(frame);
}
Enter fullscreen mode Exit fullscreen mode

Derive the visual value from elapsed time instead:

const startedAt = performance.now();
const durationMs = 10_000;

function frame(now) {
  const progress = Math.min((now - startedAt) / durationMs, 1);
  drawProgress(progress);

  if (progress < 1) {
    requestAnimationFrame(frame);
  }
}

requestAnimationFrame(frame);
Enter fullscreen mode Exit fullscreen mode

After the tab returns, the next frame renders the current position. It does not replay frames nobody saw.

For a deadline that must survive device sleep or be shared between clients, use a server timestamp or another durable wall-clock source. An animation clock and a business deadline are different jobs.

WebSockets need resynchronisation too

socket.readyState === WebSocket.OPEN tells us the browser has not marked the socket closed. It does not prove that the UI received every event while the laptop slept, the network changed, or the page was frozen.

On return I prefer this sequence:

  1. compare the last confirmed server message with an expected freshness window;
  2. reconnect if the connection is stale;
  3. request events or state after the last confirmed cursor;
  4. apply events idempotently.

Reconnecting without backfilling gives us a fresh connection to potentially incomplete state.

A Web Worker can move CPU work off the main thread, but it does not turn a browser tab into a durable process. A Service Worker is event-driven and may be terminated between events. If something must happen at a specific real-world time—billing, an email, an expiry, a scheduled job—it belongs on durable infrastructure.

What I would ship

The timer drift was expected. The stale response was the part I nearly missed.

The practical rule I now use for this failure mode is simple:

  • timestamps measure time;
  • timers request an opportunity to work;
  • the server owns durable truth;
  • visibility changes trigger resynchronisation;
  • cancellation prevents yesterday’s request from overwriting today’s state.

The browser is not breaking a promise when a background callback arrives late. My mistake was treating an old callback—and an old request—as if it still belonged to the current page state.

If you run the same test in Safari or Firefox, I would be interested in the browser version, hidden duration, and largest gap—not just “it drifted.” That would make the comparison useful.

Further reading

Top comments (0)