DEV3 Technologies home

Visibility-aware polling hook

Poll an async function on an interval that pauses in hidden tabs, backs off on errors and cancels in-flight work on unmount.

React / Next.jsHooksreactpollinghookvisibility

Code

import { useCallback, useEffect, useRef, useState } from 'react';

type Options = { interval?: number; maxDelay?: number; enabled?: boolean };

export function usePolling<T>(fn: (signal: AbortSignal) => Promise<T>, { interval = 5000, maxDelay = 60000, enabled = true }: Options = {}) {
  const [data, setData] = useState<T>();
  const [error, setError] = useState<unknown>();
  const fnRef = useRef(fn);
  fnRef.current = fn;
  const runNow = useRef<() => void>(() => {});

  useEffect(() => {
    if (!enabled) return;
    const ctrl = new AbortController();
    let timer: ReturnType<typeof setTimeout> | undefined;
    let failures = 0;

    const tick = async () => {
      clearTimeout(timer);
      if (document.visibilityState === 'hidden') return; // resumed by visibilitychange
      try {
        setData(await fnRef.current(ctrl.signal));
        setError(undefined);
        failures = 0;
      } catch (e) {
        if (ctrl.signal.aborted) return;
        setError(e);
        failures += 1;
      }
      // Next run is scheduled only after this one finishes: no overlapping requests.
      const delay = Math.min(maxDelay, interval * 2 ** failures);
      if (!ctrl.signal.aborted) timer = setTimeout(tick, delay);
    };

    const onVisible = () => document.visibilityState === 'visible' && void tick();
    runNow.current = () => void tick();
    document.addEventListener('visibilitychange', onVisible);
    void tick();
    return () => {
      ctrl.abort();
      clearTimeout(timer);
      document.removeEventListener('visibilitychange', onVisible);
    };
  }, [interval, maxDelay, enabled]);

  const refresh = useCallback(() => runNow.current(), []);
  return { data, error, refresh };
}

How it works

What it solves

Live lists such as incoming webhook requests, job status or order queues are often refreshed with a plain setInterval. That keeps hammering the server from background tabs, stacks overlapping requests when the server is slow, and retries failures at full speed. This hook polls politely.

How it works

The hook uses a recursive setTimeout instead of setInterval, so the next request is only scheduled after the current one finishes. That removes overlap by design. Each run receives an AbortSignal from an AbortController; on unmount or when options change, the effect aborts the in-flight call and clears the timer.

When document.visibilityState is hidden, the loop stops scheduling. A visibilitychange listener resumes it and triggers an immediate refresh when the tab returns, so users see fresh data without waiting for the next tick.

Failures increase the delay exponentially: after each consecutive error the wait doubles up to a cap, and a success resets it to the base interval. The latest fn is stored in a ref so changing the callback does not restart polling, and the returned refresh function lets a button force a poll right away.

Adapting it

  • Add a maxFailures option that stops polling and exposes the error.
  • Add jitter to the delay so many clients do not synchronise.
  • Pause polling while the browser is offline by listening to the online event.

Pitfalls

  • Your function must honour signal, for example by passing it to fetch, or abort will not stop it.
  • Ignore AbortError in your own error handling.

How to use it

  1. Save as hooks/usePolling.ts.
  2. const { data, error, refresh } = usePolling((signal) => fetch('/api/requests', { signal }).then((r) => r.json()), { interval: 3000 }).
  3. Render data and a refresh button calling refresh().
  4. Set enabled: false to pause polling manually.

Licence: DEV3 Snippet Licence. Free to use, change and ship in personal and commercial projects. Attribution is appreciated, not required. Please do not redistribute snippets as a collection.

Related snippets