| name | smart-poll-loop |
| description | Adaptive polling pattern with exponential backoff on failure, automatic recovery on success, and visibility-aware scheduling |
Smart Poll Loop Pattern
Overview
The Smart Poll Loop pattern implements resilient, adaptive polling for periodic data refresh operations. It combines exponential backoff on consecutive failures with automatic recovery on success, ensuring system resources are protected during outages while maintaining responsiveness when services are healthy.
This pattern is essential for production applications that need to poll external services or APIs continuously but must gracefully handle intermittent failures, rate limits, or temporary service disruptions without overwhelming the system or degrading user experience.
Key Patterns Identified
Pattern 1: Exponential Backoff on Failure
Purpose: Prevent overwhelming failing services with repeated requests while giving them time to recover.
Implementation: Each consecutive failure doubles the polling interval up to a configurable maximum multiplier. This creates exponentially increasing delays: 1x → 2x → 4x → 8x (default max).
Key Elements:
consecutiveFailures: Counter tracking sequential failures
currentBackoffMultiplier: Current delay multiplier (starts at 1)
maxBackoffMultiplier: Upper bound to prevent infinite delays (typically 4-8x)
- Boolean return from callbacks (
true = success, false = failure)
Code Pattern:
interface RunnerEntry {
consecutiveFailures: number;
currentBackoffMultiplier: number;
intervalMs: number;
}
if (!success) {
entry.consecutiveFailures++;
const newMultiplier = Math.min(
Math.pow(2, entry.consecutiveFailures),
maxBackoffMultiplier
);
entry.currentBackoffMultiplier = newMultiplier;
const effectiveInterval = entry.intervalMs * entry.currentBackoffMultiplier;
scheduleNextRun(effectiveInterval);
}
Pattern 2: Automatic Recovery on Success
Purpose: Immediately restore normal polling frequency when a service recovers, ensuring fresh data flows without unnecessary delay.
Implementation: On any successful callback execution, reset both the failure counter and backoff multiplier to their initial states (0 and 1), then reschedule at the base interval.
Key Elements:
- Reset logic triggered by
success === true or no error thrown
- Immediate rescheduling at base interval (no waiting for current timer)
- Logging state transitions for observability
Code Pattern:
if (success) {
if (entry.consecutiveFailures > 0 || entry.currentBackoffMultiplier > 1) {
entry.consecutiveFailures = 0;
entry.currentBackoffMultiplier = 1;
if (!paused && entry.timer) {
scheduleNextRun(name, entry);
}
}
}
Pattern 3: Failure Detection via Boolean Return or Exception
Purpose: Provide flexible signaling for success/failure states, supporting both explicit boolean returns and exception-based error handling.
Implementation: Callbacks can return boolean (explicit success/failure), void (success assumed), or throw exceptions (failure). All three mechanisms trigger the same backoff/recovery logic.
Key Elements:
- Type signature:
() => Promise<boolean | void>
true = explicit success, triggers recovery
false = explicit failure, triggers backoff
void/undefined = implicit success (no news is good news)
- Exception thrown = failure, triggers backoff
Code Pattern:
let success = true;
try {
const result = await entry.fn();
if (typeof result === 'boolean') {
success = result;
}
if (success) {
} else {
}
} catch (err) {
success = false;
}
Pattern 4: Visibility-Aware Scheduling
Purpose: Pause or slow down polling when the page is hidden to conserve resources, then resume or refresh immediately when visible.
Implementation: Listen to visibilitychange events. On hide, clear all timers. On show, either flush stale data (if enough time passed) or resume normal polling.
Key Elements:
document.hidden state check
pauseWhenHidden flag (full stop vs slower polling)
hiddenSince timestamp for staleness detection
- Staggered flush on resume (prevent request bursts)
Code Pattern:
private onVisibilityChange(): void {
if (document.hidden) {
this.hiddenSince = Date.now();
for (const entry of this.runners.values()) {
if (entry.timer) {
clearInterval(entry.timer);
entry.timer = null;
}
}
} else {
this.flushStaleRefreshes();
for (const [name, entry] of this.runners) {
if (!entry.timer) {
this.scheduleNextRun(name, entry);
}
}
}
}
private flushStaleRefreshes(): void {
if (!this.hiddenSince) return;
const hiddenMs = Date.now() - this.hiddenSince;
. = ;
stagger = ;
( [name, entry] .) {
effectiveInterval = entry. * entry.;
(hiddenMs < effectiveInterval) ;
( .(name), stagger);
stagger += ;
}
}
Pattern 5: Per-Runner State Management
Purpose: Allow multiple independent polling loops with isolated failure tracking and backoff state.
Implementation: Use a map keyed by runner name, storing all state (timer, interval, backoff, failures) per entry. Each runner progresses through its own backoff cycle independently.
Key Elements:
Map<string, RunnerEntry> for isolated state
- Unique names prevent conflicts
- Per-runner failure counters
- Per-runner backoff multipliers
- In-flight tracking prevents overlapping executions
Code Pattern:
private runners = new Map<string, RunnerEntry>();
private inFlight = new Set<string>();
scheduleRefresh(
name: string,
fn: () => Promise<boolean | void>,
intervalMs: number,
): void {
const entry: RunnerEntry = {
timer: null,
intervalMs,
fn,
lastRun: 0,
consecutiveFailures: 0,
currentBackoffMultiplier: 1,
};
this.runners.set(name, entry);
this.scheduleNextRun(name, entry);
}
private async runRefresh(name: string): Promise<void> {
const entry = this.runners.get(name);
if (!entry) return;
(..(name)) ;
..(name);
{
} {
..(name);
}
}
Complete Code Template
Below is a minimal but complete implementation of the adaptive polling pattern:
interface RefreshRegistration {
name: string;
fn: () => Promise<boolean | void>;
intervalMs: number;
condition?: () => boolean;
}
interface RunnerEntry {
timer: ReturnType<typeof setInterval> | null;
intervalMs: number;
fn: () => Promise<boolean | void>;
condition?: () => boolean;
lastRun: number;
consecutiveFailures: number;
currentBackoffMultiplier: number;
}
class AdaptiveScheduler {
private runners = new Map<string, RunnerEntry>();
private inFlight = new Set<string>();
private hiddenSince = 0;
private readonly maxBackoffMultiplier = ;
() {
.(, .());
}
(): {
(.) {
. = .();
( entry ..()) {
(entry.) {
(entry.);
entry. = ;
}
}
} {
.();
( [name, entry] .) {
(!entry.) {
.(name, entry);
}
}
}
}
(: , : ): {
(entry.) (entry.);
effectiveInterval = entry. * entry.;
entry. = ( .(name), effectiveInterval);
}
(: ): <> {
entry = ..(name);
(!entry) ;
(..(name)) ;
(entry. && !entry.()) ;
..(name);
success = ;
{
result = entry.();
( result === ) {
success = result;
}
entry. = .();
(success) {
(entry. > || entry. > ) {
entry. = ;
entry. = ;
(!. && entry.) {
.(name, entry);
}
}
} {
entry.++;
newMultiplier = .(
.(, entry.),
.
);
(newMultiplier !== entry.) {
entry. = newMultiplier;
(!. && entry.) {
.(name, entry);
}
}
}
} (err) {
success = ;
entry.++;
newMultiplier = .(
.(, entry.),
.
);
(newMultiplier !== entry.) {
entry. = newMultiplier;
(!. && entry.) {
.(name, entry);
}
}
.(, err);
} {
..(name);
}
}
(): {
(!.) ;
hiddenMs = .() - .;
. = ;
stagger = ;
( [name, entry] .) {
effectiveInterval = entry. * entry.;
(hiddenMs < effectiveInterval) ;
( .(name), stagger);
stagger += ;
}
}
(
: ,
: < | >,
: ,
?: ,
): {
existing = ..(name);
(existing?.) (existing.);
: = {
: ,
intervalMs,
fn,
condition,
: ,
: ,
: ,
};
(!.) {
.(name, entry);
}
..(name, entry);
}
(: ): {
.(name);
}
(): {
( entry ..()) {
(entry.) (entry.);
}
..();
..();
}
}
Usage Examples
Example 1: Basic Polling with Explicit Success/Failure
const scheduler = new AdaptiveScheduler();
scheduler.scheduleRefresh(
'fetch-stock-prices',
async () => {
try {
const response = await fetch('/api/stocks');
if (!response.ok) return false;
const data = await response.json();
updateStockPrices(data);
return true;
} catch (err) {
console.error('Stock fetch failed:', err);
return false;
}
},
30_000
);
Behavior:
- On success: polls every 30s
- After 1 failure: polls every 60s (2x)
- After 2 failures: polls every 120s (4x)
- After 3+ failures: polls every 240s (8x, max)
- On any success: immediately returns to 30s interval
Example 2: Multiple Independent Runners
const scheduler = new AdaptiveScheduler();
scheduler.scheduleRefresh(
'weather',
async () => {
const res = await fetch('/api/weather');
return res.ok;
},
60_000
);
scheduler.scheduleRefresh(
'news',
async () => {
const res = await fetch('/api/news');
if (!res.ok) return false;
const articles = await res.json();
updateNewsFeed(articles);
return true;
},
300_000
);
Example 3: Conditional Execution
scheduler.scheduleRefresh(
'user-notifications',
async () => {
const res = await fetch('/api/notifications');
if (!res.ok) return false;
const notifications = await res.json();
displayNotifications(notifications);
return true;
},
60_000,
() => isUserLoggedIn()
);
Example 4: Void Return (Implicit Success)
scheduler.scheduleRefresh(
'analytics-heartbeat',
async () => {
await fetch('/api/analytics/heartbeat', { method: 'POST' });
},
120_000
);
Example 5: Manual Trigger
scheduler.scheduleRefresh('dashboard-data', fetchDashboard, 60_000);
document.getElementById('refresh-btn')?.addEventListener('click', () => {
scheduler.trigger('dashboard-data');
});
Best Practices
Choose Appropriate Base Intervals
- High-frequency data (stock prices, live scores): 10-30 seconds
- Medium-frequency data (weather, news): 1-5 minutes
- Low-frequency data (configuration, settings): 10-30 minutes
Set Reasonable Backoff Limits
- Max multiplier 4-8x: Prevents indefinite delays while allowing sufficient recovery time
- Too low (2x): May overwhelm failing services
- Too high (16x+): Data may become too stale during recovery
Use Explicit Boolean Returns When Possible
async () => {
const res = await fetch('/api/data');
if (res.status === 429) return false;
if (res.status === 503) return false;
if (res.status === 404) return true;
return res.ok;
}
async () => {
const res = await fetch('/api/data');
res.json();
}
Handle Stale Data on Resume
When the page becomes visible after being hidden, stale refreshes are flushed with staggering. Ensure your data handlers can cope with rapid updates:
let updateTimer: number | null = null;
function updateUI(data: any) {
if (updateTimer) clearTimeout(updateTimer);
updateTimer = setTimeout(() => {
renderData(data);
}, 100);
}
Monitor Backoff State
Expose backoff state for debugging and monitoring:
const state = scheduler.getBackoffState('api-poller');
console.log(`Failures: ${state.failures}, Multiplier: ${state.multiplier}x`);
scheduler.resetBackoff('api-poller');
Clean Up on Component Unmount
useEffect(() => {
const scheduler = new AdaptiveScheduler();
scheduler.scheduleRefresh('data', fetchData, 30_000);
return () => scheduler.destroy();
}, []);
When to Use This Pattern
Ideal For:
- Polling external APIs that may experience intermittent failures or rate limits
- Dashboard/monitoring UIs that need to stay fresh but must handle service outages gracefully
- Real-time-ish data where strict real-time isn't required (use WebSockets for true real-time)
- Multiple data sources with different refresh rates and reliability profiles
Not Ideal For:
- True real-time requirements: Use WebSockets, Server-Sent Events, or long polling instead
- One-time operations: Use direct async calls, not scheduled polling
- Critical, must-not-miss updates: Add push notifications or webhooks as a complement
- High-frequency sub-second polling: Consider WebSocket or EventSource
Common Pitfalls to Avoid
Thundering Herd on Resume
Problem: All runners trigger simultaneously when page becomes visible.
Solution: Use staggered flush (already implemented in the pattern):
let stagger = 0;
for (const [name, entry] of this.runners) {
setTimeout(() => this.runRefresh(name), stagger);
stagger += 150;
}
Backoff Not Resetting
Problem: Forgetting to reset backoff on success keeps the service in slow-poll mode forever.
Solution: Always check and reset state on success:
if (success && entry.currentBackoffMultiplier > 1) {
entry.currentBackoffMultiplier = 1;
entry.consecutiveFailures = 0;
reschedule();
}
Overlapping Executions
Problem: Long-running callbacks overlap with the next scheduled run.
Solution: Use in-flight tracking (already implemented):
if (this.inFlight.has(name)) return;
this.inFlight.add(name);
try {
await callback();
} finally {
this.inFlight.delete(name);
}
Ignoring Visibility State
Problem: Polling continues in hidden tabs, wasting resources and battery.
Solution: Always implement visibility-aware pausing (already implemented):
if (document.hidden) {
clearAllTimers();
} else {
restartTimers();
}
Related Patterns
- Circuit Breaker: After N consecutive failures, stop polling entirely until manual reset or timeout
- Jittered Backoff: Add randomization to backoff delays to prevent synchronized retries across clients
- Adaptive Intervals: Adjust base interval based on data change frequency (not just failures)
- WebSocket with Fallback: Use WebSocket for real-time, fall back to smart polling on connection loss
Performance Considerations
Memory Usage
Each runner entry stores:
- Timer reference: ~8 bytes
- Function reference: ~8 bytes
- State integers: ~16 bytes
- Total: ~32-64 bytes per runner
For 100 concurrent runners: ~6 KB overhead (negligible).
CPU Usage
- Idle state (no failures): Minimal - just timer callbacks
- During backoff: Reduced CPU usage due to longer intervals
- On resume: Brief spike from staggered flush, then normal
Network Usage
- Normal operation: Consistent request rate
- During failures: Exponentially reduced request rate (desired behavior)
- On recovery: Immediate return to normal rate
Testing Strategies
Unit Tests
describe('AdaptiveScheduler', () => {
it('should double interval on consecutive failures', async () => {
const scheduler = new AdaptiveScheduler();
let callCount = 0;
scheduler.scheduleRefresh('test', async () => {
callCount++;
return false;
}, 1000);
await sleep(1000);
expect(callCount).toBe(1);
await sleep(2000);
expect(callCount).toBe(2);
await sleep(4000);
expect(callCount).toBe(3);
});
it('should reset backoff on success', async () => {
const scheduler = new AdaptiveScheduler();
let shouldFail = true;
scheduler.scheduleRefresh('test', async () => {
!shouldFail;
}, );
shouldFail = ;
scheduler.();
state = scheduler.();
(state.).();
(state.).();
});
});
Integration Tests
Mock fetch to simulate service failures and recoveries, verify backoff timing and state transitions.
Manual Testing
Use browser DevTools to throttle network, simulate hidden/visible transitions, and observe polling behavior in the Network tab.
References