@watchgold/scheduler
v0.1.0
Published
A small client-side scheduler for live-data UIs: visibility-aware polling with backoff and reentrancy guards, a React usePoll hook with last-known-good data, and a batched beacon event queue.
Maintainers
Readme
@watchgold/scheduler
A small client-side scheduler for live-data UIs: visibility-aware polling with backoff and reentrancy guards, a React usePoll hook with last-known-good data, and a batched beacon event queue.
Why
This package was distilled from a production market-data app that had grown 14 independent setInterval poll sites — price tickers, chart refreshers, status watchers, an analytics flusher. Each one re-implemented a fragment of a real poller: one had exponential backoff, another had a reentrancy guard, a third varied its rate by view mode. None of them were page-visibility aware, so every background tab kept hammering the backend forever — a tab parked over a weekend produced thousands of requests nobody would ever see.
@watchgold/scheduler unifies the good parts into one core and makes visibility pausing the default:
- setTimeout chain, not
setInterval— ticks can never stack, and the interval can change between ticks. - Reentrancy guard — a tick that comes due while the previous round is still in flight is skipped and rescheduled, so a slow backend can't pile up concurrent requests.
- Backoff — failures retry at 2s / 4s / 8s (configurable), then return to the normal cadence.
- Visibility aware — a hidden document skips ticks entirely (no timer chain kept alive); on reveal, the missed tick runs immediately so the user never stares at stale data.
- Last-known-good UI semantics — the React hook keeps previous data on screen and flags it
isStaleinstead of tearing the UI down to an error state. - Beacon-safe event batching — a generic batch queue with timer/size/unload flushing via
navigator.sendBeacon, extracted from a first-party analytics client.
The core (createPoller, createBatchQueue) is framework-free; React is an optional peer dependency needed only by usePoll.
Install
npm install @watchgold/scheduler
# react ^18 || ^19 only if you use the usePoll hookQuick start
createPoller — framework-free polling
import { createPoller } from '@watchgold/scheduler';
const poller = createPoller(
async ({ signal }) => {
const res = await fetch('/api/quotes/latest', { signal });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
render(await res.json());
},
{
// Rate by mode: re-evaluated before every tick.
intervalMs: () => (isIntradayView() ? 60_000 : 300_000),
backoff: { baseDelayMs: 1000, factor: 2, maxRetries: 3 }, // 2s/4s/8s (the default)
onError: (err, state) => console.warn('poll failed', state.consecutiveFailures, err),
},
);
poller.start(); // runs immediately, then per interval
await poller.refresh(); // force a round now (resets the cadence)
poller.stop(); // clears the timer and aborts the in-flight requestA task can end its own polling by returning the literal false — useful for "poll until this job settles" watchers:
const watcher = createPoller(async () => {
const job = await fetchJobStatus();
if (job.done) return false; // self-terminate: nothing left to poll
}, { intervalMs: 8000 });
watcher.start();usePoll — React hook with last-known-good data
'use client';
import { usePoll } from '@watchgold/scheduler';
function PriceCard({ ssrSnapshot, ssrFetchedAt }: Props) {
const { data, isStale, error, lastUpdatedAt, refresh } = usePoll(
({ signal }) => fetchLatestPrices(signal),
{
intervalMs: 60_000,
initialData: ssrSnapshot, // shown until the first client fetch lands
initialUpdatedAt: ssrFetchedAt, // epoch ms of the snapshot
},
);
if (!data && error) return <ErrorState onRetry={refresh} />;
return <Prices data={data} stale={isStale} updatedAt={lastUpdatedAt} />;
}On fetch failure the previous (or server-provided) data stays up; isStale flips true only once the backoff retries are exhausted and there is older data to show. With nothing to fall back on, error is exposed with data: null instead.
createBatchQueue + jsonBeaconTransport — batched event delivery
import { createBatchQueue, jsonBeaconTransport } from '@watchgold/scheduler';
const events = createBatchQueue({
transport: jsonBeaconTransport('/api/events'), // sendBeacon on unload, keepalive fetch otherwise
maxBatch: 50, // default — also the size-triggered flush threshold
flushIntervalMs: 8000, // default
dedup: { keyOf: (e) => `${e.name}|${e.path}` }, // drop double-fires within 1s
});
events.enqueue({ name: 'page_view', path: location.pathname, ts: Date.now() });Events flush on a timer, when the batch cap is hit, and — critically — when the page is hidden or unloaded, where the whole queue is drained in maxBatch chunks through navigator.sendBeacon. A refused beacon hand-off puts the batch back at the front of the queue and stops draining, so nothing is reordered or lost.
API
createPoller(task, options): Poller
task: (ctx: { signal: AbortSignal }) => Promise<void | false>— the polled work.signalaborts onstop(). Return literalfalseto self-terminate.
| Option | Default | Meaning |
| --- | --- | --- |
| intervalMs | required | number or () => number; the function form is re-evaluated each time the next tick is scheduled. |
| immediate | true | Run a tick on start(). |
| pauseWhenHidden | true | Skip ticks while document.visibilityState === 'hidden'. No-op without a document (SSR / node). |
| runOnReveal | true | A tick missed while hidden runs immediately when the page becomes visible again. |
| backoff | { baseDelayMs: 1000, factor: 2, maxRetries: 3 } | Retry delay after the Nth consecutive failure is baseDelayMs * factor ** N (2s/4s/8s); after maxRetries the normal cadence resumes, failures still counting until a success resets them. Pass false to disable. |
| onError(error, state) | — | Called after each failed tick. |
| onSuccess(state) | — | Called after each successful tick. |
| visibilityTarget | globalThis.document | Injectable visibility source (for tests / embedded webviews). |
Returned Poller:
start()— begin polling (no-op if running).stop()— clear the pending timer and abort the in-flight signal. An abort mid-flight is cancellation, not a failure.refresh(): Promise<void>— run a tick now and await it; joins the in-flight round instead of stacking a second one; resets the cadence.state: Readonly<PollerState>— snapshot:{ running, inFlight, consecutiveFailures, lastSuccessAt, lastError, ticks }.
Scheduling semantics worth knowing:
- A due tick while the task is in flight is skipped and rescheduled (never stacked).
- A due tick while hidden is skipped and remembered; no timer chain is kept alive while hidden.
- Starting while hidden (with
immediate) defers the first tick to reveal.
usePoll(fetcher, options): UsePollResult<T>
options extends the poller options above (minus onError/onSuccess) with:
| Option | Default | Meaning |
| --- | --- | --- |
| enabled | true | When false the poller is never started. |
| keepLastGood | true | Keep previous data (flagged isStale) when fetches fail. When false, failure clears data. |
| initialData | null | Data to show before the first fetch (e.g. an SSR snapshot). |
| initialUpdatedAt | null | Timestamp (epoch ms) of initialData. |
| restartKey | — | Change this value to tear down and recreate the poller (applies changed options). |
Result: { data, error, isStale, isPolling, lastUpdatedAt, refresh(), stop() }.
The fetcher is held in a ref — passing a fresh closure every render does not restart polling. intervalMs is re-read from the latest render on every schedule, so switching poll rates needs no restart either.
createBatchQueue(options): BatchQueue<T>
| Option | Default | Meaning |
| --- | --- | --- |
| transport | required | (batch, { beacon, reason }) => boolean \| void \| Promise<void>. In beacon mode, return false to signal the hand-off was refused. |
| maxBatch | 50 | Items per delivered batch; also the size-triggered flush threshold. |
| flushIntervalMs | 8000 | Periodic flush timer. |
| dedup | off | { keyOf(item), windowMs = 1000, maxTracked = 200 } — drops identical keys enqueued within the window (guards React Strict Mode double effects and accidental double-calls). |
| flushOnHide | true | Drain everything in beacon mode on visibilitychange → hidden and pagehide. |
Returned queue: enqueue(item): boolean (false = deduped away), flush(reason?), size(), dispose().
Flush behavior:
'hide'flushes drain the entire queue inmaxBatchchunks via beacon-mode sends; a refused hand-off re-enqueues that batch at the front and stops.- Other reasons send one batch and drain any remainder on a next-tick timeout.
- A transport that throws synchronously never took custody: the batch is put back for the next natural flush (no retry spin). Rejected promises are swallowed — batches already on the wire are best-effort.
jsonBeaconTransport(url, opts?)
The classic analytics pair: beacon mode posts a JSON Blob via navigator.sendBeacon(url, blob) (returning its accept/refuse boolean); otherwise a keepalive JSON fetch whose errors are swallowed. opts.wrap(batch) customizes the payload shape — the default produces { events: [...] }.
SSR notes
- Importing any module has zero side effects (
sideEffects: false). createPollerwithout adocumentsimply behaves like a plain timer chain — visibility handling disables itself.usePolldoes all work in effects; on the server it just returnsinitialData, which is exactly the SSR-snapshot-then-hydrate pattern it was built for.createBatchQueueinstalls its timer and page listeners lazily on the firstenqueue; in Node the flush interval isunref'd so it never holds the process open.
License
MIT
Extracted from the production codebase of WatchGold, a precious-metals market-data platform.
