i99dash docs
Recipes

Polling with cancellation

Refresh data on a timer, cancel cleanly when the user navigates away. ~5 minutes.

You want a value that refreshes every N seconds. The naïve setInterval approach leaks subscriptions and stacks up requests when the network is slow. This recipe is the version that doesn't.

What you'll exercise

  • Promise-based polling loop (no setInterval)
  • AbortController cancellation, propagated to callApi
  • Page-Visibility-aware backoff (no work while the tab is hidden)
  • Cleanup that's safe to call twice

The pattern

import { MiniAppClient, type CallApiResponse } from '@i99dash/sdk';

interface PollOptions {
  intervalMs: number;       // refresh cadence; 5_000 is a good default
  maxBackoffMs?: number;    // cap on exponential backoff after errors
}

/// Poll `path` every `intervalMs`. Returns a stop fn.
/// `onUpdate` fires on every successful response; `onError` fires on
/// failed envelopes AND thrown bridge errors.
export function poll<T>(
  client: MiniAppClient,
  path: string,
  opts: PollOptions,
  onUpdate: (data: T) => void,
  onError?: (e: { code: string; message: string }) => void,
): () => void {
  const ctl = new AbortController();
  let backoff = opts.intervalMs;
  const max = opts.maxBackoffMs ?? 60_000;

  void (async () => {
    while (!ctl.signal.aborted) {
      // Skip work while hidden. Wake-up on visibility change does
      // a fresh poll immediately.
      if (typeof document !== 'undefined' && document.hidden) {
        await waitForVisible(ctl.signal);
        if (ctl.signal.aborted) break;
      }

      try {
        const r = await client.callApi<T>(
          { path, method: 'GET' },
          { signal: ctl.signal },
        );
        if (r.success) {
          onUpdate(r.data);
          backoff = opts.intervalMs;          // reset on success
        } else {
          onError?.(r.error);
          backoff = Math.min(backoff * 2, max);  // back off on failure
        }
      } catch (e) {
        if (ctl.signal.aborted) break;        // user-cancelled; stop loop
        onError?.({
          code: 'transport',
          message: e instanceof Error ? e.message : String(e),
        });
        backoff = Math.min(backoff * 2, max);
      }

      // Sleep until the next tick OR a stop signal.
      await sleep(backoff, ctl.signal);
    }
  })();

  return () => ctl.abort();
}

async function sleep(ms: number, signal: AbortSignal): Promise<void> {
  if (signal.aborted) return;
  await new Promise<void>((resolve) => {
    const id = setTimeout(resolve, ms);
    signal.addEventListener('abort', () => {
      clearTimeout(id);
      resolve();
    }, { once: true });
  });
}

async function waitForVisible(signal: AbortSignal): Promise<void> {
  if (signal.aborted) return;
  if (typeof document === 'undefined' || !document.hidden) return;
  await new Promise<void>((resolve) => {
    const handler = () => {
      if (!document.hidden) {
        document.removeEventListener('visibilitychange', handler);
        resolve();
      }
    };
    signal.addEventListener('abort', () => {
      document.removeEventListener('visibilitychange', handler);
      resolve();
    }, { once: true });
    document.addEventListener('visibilitychange', handler);
  });
}

Use it

const client = MiniAppClient.fromWindow();

const stop = poll<{ stations: Station[] }>(
  client,
  '/api/v1/fuel-stations',
  { intervalMs: 5_000 },
  (data) => render(data.stations),
  (err) => showBanner(err.message),
);

// On unmount / navigation:
stop();

In React, wrap with useEffect:

useEffect(() => {
  const stop = poll(client, '/api/v1/fuel-stations', { intervalMs: 5_000 }, setData);
  return stop;
}, [client]);

Why this and not setInterval?

setInterval(fn, 5_000)This pattern
Stacks up requests if the previous one hasn't returned (network slow)Awaits each request before sleeping; never overlaps
Runs while the tab is hidden, burning bandwidthPauses on document.hidden; resumes immediately on return
Constant cadence even after errorsExponential backoff up to maxBackoffMs on failure
Hard to cancel mid-requestAbortController propagates into the in-flight callApi
Cleanup races with in-flight callbackssignal.aborted checked at every yield point

Edge cases

  • Stop called during an in-flight call — the AbortController rejects the SDK's withTimeout wrapper with an AbortError. The loop catches it, sees signal.aborted, exits cleanly. No late onUpdate fires.
  • Tab hidden mid-request — the in-flight call completes (the host doesn't pause its end). The result is delivered. Then the loop notices document.hidden and waits.
  • Errored backoff — successful response resets backoff to intervalMs. If the backend is flaky, you get a sane recovery curve instead of permanently degraded cadence.

On this page