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) AbortControllercancellation, propagated tocallApi- 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 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 callApi |
| 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 SDK'swithTimeoutwrapper 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 your backend —
callApi's signal/timeout contract. - Subscriptions — for push updates instead of polling, prefer this.
- Best practices — performance + cleanup rules.