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

jattac-web-poller

v1.0.0

Published

Generic polling utility with linear backoff, delta detection, and graceful stop.

Downloads

35

Readme

@jattac/web-poller

A TypeScript polling utility with linear backoff, change detection, and graceful stop. Works in browser and Node.js.

Features

  • Polls any async function on a configurable interval
  • Calls back on every result, with a deltaDetected flag so you can react only to changes
  • Linear backoff when results aren't changing — resets automatically when they do
  • stop() returns a Promise that resolves only after any in-flight poll finishes
  • pollNow() for immediate out-of-schedule polls
  • Circuit-breaker via maxConsecutiveErrors
  • Zero runtime dependencies

Installation

npm install @jattac/web-poller

Quick start

import Poller from '@jattac/web-poller';

const poller = new Poller({
  func: () => fetch('/api/status').then(r => r.json()),
  onResult: (result, deltaDetected) => {
    if (deltaDetected) {
      console.log('Status changed:', result);
    }
  },
});

poller.start();

// Later, when tearing down:
await poller.stop();

Constructor

new Poller<T>(args: PollerArgs<T>)

Options

| Option | Type | Default | Description | |---|---|---|---| | func | () => Promise<T> | required | The async function to call on each poll. | | pollIntervalMilliseconds | number | 5000 | Base interval between polls in ms. | | maxIntervalMilliseconds | number | 60000 | Maximum interval the backoff will grow to. | | hasDelta | (args: DeltaCheckArgs<T>) => boolean | deep JSON equality | Returns true if the new result differs from the previous one. | | onResult | (result: T, deltaDetected: boolean) => void \| Promise<void> | — | Called after every successful poll. | | onError | (error: unknown, consecutiveErrorCount: number) => void | logs to console | Called when func throws. | | maxConsecutiveErrors | number | — | Stop automatically after this many consecutive errors. | | onMaxErrors | () => void | — | Called just before the poller stops due to maxConsecutiveErrors. |


Methods

start()

Begins polling immediately. Resets the backoff state to the base interval. No-op if already running.

poller.start();

When to use: Initial start, or to restart from scratch after a full stop.

stop(): Promise<void>

Cancels the next scheduled poll and returns a Promise that resolves once any currently in-flight poll has finished. Preserves backoff state so resume() can pick up where polling left off.

await poller.stop(); // safe to call before cleanup

Important: Always await this before disposing of resources that func or onResult may reference. Without await, an in-flight poll could call back into a torn-down component.

resume()

Continues polling after stop(). Unlike start(), it does not reset the backoff — the interval stays where it was when polling stopped. No-op if already running.

poller.resume(); // picks up at the backed-off interval

When to use: Temporary pauses — e.g., tab blur/focus, component unmount/remount — where you want to preserve the current backoff level rather than thrashing the server on re-mount.

pollNow()

Cancels any pending scheduled poll and triggers an immediate poll. No-op if the poller is cancelled. Useful when the user performs an action that is likely to have changed the resource.

button.addEventListener('click', () => poller.pollNow());

resetBackoff()

Resets noDeltaCount and currentInterval back to pollIntervalMilliseconds. The poller continues running at the base rate.

poller.resetBackoff(); // e.g. after a known write that will produce a delta

State

The state getter returns a snapshot:

const s = poller.state;

s.isRunning           // true while func() is in flight
s.isCancelled         // true after stop()
s.currentInterval     // current ms between polls
s.baseInterval        // the configured base interval
s.maxInterval         // the configured max interval
s.lastPollTime        // Date of the last completed poll (or null)
s.nextPollTime        // Date when the next poll is scheduled (or null)
s.noDeltaCount        // consecutive no-delta polls since last reset
s.consecutiveErrorCount  // consecutive errors since last success
s.previousResult      // the last successful result (or null)

Note: previousResult is a reference to the internal value, not a deep copy. Do not mutate it.


Backoff behaviour

The poller uses linear backoff, not exponential. Each no-delta poll increases the interval by a constant step, calculated so that the interval reaches maxIntervalMilliseconds over a ~60-minute window.

With defaults (base=5s, max=60s):

  • Step ≈ 0.45 s per no-delta poll
  • Reaches 60s after ~122 no-delta polls (~7.5 hours of no changes at the stepped rate)

When a delta is detected, the interval resets to base immediately.

Why linear instead of exponential? Exponential backoff is designed for retry scenarios where each attempt is independent. Here, polling is continuous — an aggressive doubling strategy can mean you miss a change for 30+ minutes if you happen to poll at the wrong moment after a quiet period. Linear backoff provides a smoother progression and ensures you're checking at maxIntervalMilliseconds at most, rather than potentially missing arbitrarily long windows.


hasDelta and its pitfalls

The default uses JSON.stringify to detect changes. This is good enough for plain objects and primitives, but it fails silently in several cases:

| Case | Behaviour | |---|---| | undefined-valued keys | Keys with undefined values are omitted from JSON, so { a: undefined } equals {} | | NaN | Serialised to null, so NaN !== NaN is not detected | | Map / Set | Serialised to {} / [], so contents are never compared | | Date | Serialised to ISO string — works, but only if both sides are Date objects or both are strings | | Circular references | Throws TypeError | | Property order | JSON.stringify preserves insertion order, so { a:1, b:2 }{ b:2, a:1 } — can produce false positives |

For anything more complex, pass a custom hasDelta:

import isEqual from 'lodash.isequal';

new Poller({
  func: fetchData,
  hasDelta: ({ previousResult, currentResult }) =>
    !isEqual(previousResult, currentResult),
});

Error handling

Errors from func() do not stop the poller by default — it keeps running and schedules the next poll at the current interval. The consecutiveErrorCount in state tracks how many errors have occurred without a successful poll.

Providing onError

If onError is not provided, errors are printed to console.error. Always provide onError in production code:

new Poller({
  func: fetchData,
  onError: (err, count) => {
    logger.warn(`Poll failed (attempt ${count})`, err);
  },
});

Circuit breaker

Use maxConsecutiveErrors to stop after N failures. Pair it with onMaxErrors to notify your error-tracking system:

new Poller({
  func: fetchData,
  maxConsecutiveErrors: 5,
  onMaxErrors: () => {
    Sentry.captureMessage('Poller stopped: 5 consecutive failures');
    showOfflineBanner();
  },
  onError: (err, count) => logger.warn(`Poll error #${count}`, err),
});

Best practices

Always await stop() before cleanup

// ✓ correct
useEffect(() => {
  poller.start();
  return () => { void poller.stop(); };
}, []);

// ✓ even better in async contexts
await poller.stop();
disposeResources();

Use resume() for temporary pauses

document.addEventListener('visibilitychange', () => {
  if (document.hidden) {
    void poller.stop();
  } else {
    poller.resume(); // preserves backoff
  }
});

Trigger pollNow() after writes

async function saveSettings(data) {
  await api.save(data);
  poller.pollNow(); // don't wait for the next scheduled poll
}

Set maxConsecutiveErrors in production

Without it, the poller will keep trying forever even if the endpoint is completely down. A limit of 5–10 is usually appropriate; pair it with onMaxErrors to surface the failure to the user.

Keep func focused

func should only fetch — all side effects belong in onResult. This keeps func testable in isolation and ensures onResult is only called when a fetch actually succeeded.

// ✓
new Poller({
  func: () => api.getStatus(),
  onResult: (status, changed) => {
    if (changed) updateUI(status);
  },
});

// ✗ — side effects in func are not called through onResult
new Poller({
  func: async () => {
    const status = await api.getStatus();
    updateUI(status); // don't do this here
    return status;
  },
});

Use a custom hasDelta for non-plain-object results

See the hasDelta pitfalls section above.


TypeScript

All public types are exported:

import Poller, { type PollerArgs, type PollerState, type DeltaCheckArgs } from '@jattac/web-poller';

The class is generic over the result type T:

interface Status { ok: boolean; message: string }

const poller = new Poller<Status>({
  func: (): Promise<Status> => fetch('/health').then(r => r.json()),
  onResult: (result: Status, deltaDetected: boolean) => { /* ... */ },
});

License

MIT