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) AbortControllercancellation, propagated intofetch()- Page-Visibility-aware backoff (no work while the tab is hidden)
- Cleanup that's safe to call twice
The data comes from an external HTTPS API you reach with plain
fetch() — so the API's origin must be declared in your
manifest.network.
The pattern
interface PollOptions {
intervalMs: number; // refresh cadence; 5_000 is a good default
maxBackoffMs?: number; // cap on exponential backoff after errors
}
/// Poll `url` every `intervalMs`. Returns a stop fn.
/// `onUpdate` fires on every successful response; `onError` fires on
/// non-2xx responses AND on fetch rejections (network / CSP block).
export function poll<T>(
url: 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 res = await fetch(url, { signal: ctl.signal });
if (res.ok) {
onUpdate((await res.json()) as T);
backoff = opts.intervalMs; // reset on success
} else {
onError?.({ code: `http_${res.status}`, message: res.statusText });
backoff = Math.min(backoff * 2, max); // back off on failure
}
} catch (e) {
if (ctl.signal.aborted) break; // user-cancelled; stop loop
// network down OR a CSP block (origin not in manifest.network)
onError?.({
code: 'network',
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
// manifest.json → "network": ["https://api.fuel.example.com"]
const stop = poll<{ stations: Station[] }>(
'https://api.fuel.example.com/v1/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('https://api.fuel.example.com/v1/stations', { intervalMs: 5_000 }, setData);
return stop;
}, []);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 bandwidth | Pauses on document.hidden; resumes immediately on return |
| Constant cadence even after errors | Exponential backoff up to maxBackoffMs on failure |
| Hard to cancel mid-request | AbortController propagates into the in-flight fetch() |
| Cleanup races with in-flight callbacks | signal.aborted checked at every yield point |
Edge cases
- Stop called during an in-flight call — the
AbortControllerrejects the in-flightfetch()with anAbortError. The loop catches it, seessignal.aborted, exits cleanly. No lateonUpdatefires. - 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.hiddenand 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.
Related
AbortController— DOM standard.- Calling an external API — declaring the origin you
fetch(). - Subscriptions — for push updates instead of polling, prefer this.
- Best practices — performance + cleanup rules.
Now-playing media widget
A live "what's playing in the cabin" tile. The v5.0 BYD catalog does not yet expose media signals; this recipe sketches the shape you'll use the moment the host's catalog ships `category: 'media'` (v5.1).
Prayer times (offline-first)
An Islamic prayer-times + Qibla app that computes everything locally with the adhan package, then optionally enriches with a declared-egress fetch to the Aladhan API. ~20 minutes.