npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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.

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 isStale instead 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 hook

Quick 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 request

A 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. signal aborts on stop(). Return literal false to 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 in maxBatch chunks 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).
  • createPoller without a document simply behaves like a plain timer chain — visibility handling disables itself.
  • usePoll does all work in effects; on the server it just returns initialData, which is exactly the SSR-snapshot-then-hydrate pattern it was built for.
  • createBatchQueue installs its timer and page listeners lazily on the first enqueue; in Node the flush interval is unref'd so it never holds the process open.

License

MIT

Extracted from the production codebase of WatchGold, a precious-metals market-data platform.