jattac-web-poller
v1.0.0
Published
Generic polling utility with linear backoff, delta detection, and graceful stop.
Downloads
35
Maintainers
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
deltaDetectedflag 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 finishespollNow()for immediate out-of-schedule polls- Circuit-breaker via
maxConsecutiveErrors - Zero runtime dependencies
Installation
npm install @jattac/web-pollerQuick 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 cleanupImportant: 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 intervalWhen 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 deltaState
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:
previousResultis 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
