Raise your hand if you've written this:
function pollStatus() {
fetch('/api/status')
.then(res => res.json())
.then(data => {
if (data.status === 'pending') {
setTimeout(pollStatus, 2000);
} else {
handleComplete(data);
}
});
}
It works. Until it doesn't.
I've debugged this pattern more times than I want to admit. The problems are always the same: requests pile up when the server is slow, the tab keeps polling when the user walks away, and there's no backoff when things go wrong.
Here are three better patterns, ranked from simple to bulletproof.
1. Use setInterval + a Lock (Quick Fix)
The simplest improvement: don't fire the next poll until the current one finishes.
let polling = false;
const interval = setInterval(async () => {
if (polling) return;
polling = true;
try {
const res = await fetch('/api/status');
const data = await res.json();
if (data.status !== 'pending') {
clearInterval(interval);
handleComplete(data);
}
} catch (err) {
console.error('Poll failed:', err);
} finally {
polling = false;
}
}, 2000);
This prevents request stacking. But it still polls when the tab is hidden, and there's no backoff.
When to use: Internal tools, admin dashboards — anywhere "good enough" is fine.
2. Exponential Backoff + Visibility API (Production Ready)
This is what you actually want in production:
class SmartPoller {
constructor(fn, { baseMs = 1000, maxMs = 30000, factor = 1.5 } = {}) {
this.fn = fn;
this.baseMs = baseMs;
this.maxMs = maxMs;
this.factor = factor;
this.currentMs = baseMs;
this.timer = null;
this.stopped = false;
document.addEventListener('visibilitychange', () => {
if (document.hidden) {
this.pause();
} else {
this.currentMs = this.baseMs;
this.poll();
}
});
}
async poll() {
if (this.stopped) return;
try {
const result = await this.fn();
if (result?.done) {
this.stop();
return;
}
this.currentMs = this.baseMs;
} catch (err) {
this.currentMs = Math.min(this.currentMs * this.factor, this.maxMs);
console.warn(`Poll failed, retrying in ${this.currentMs}ms`);
}
this.timer = setTimeout(() => this.poll(), this.currentMs);
}
start() {
this.stopped = false;
this.poll();
}
pause() {
clearTimeout(this.timer);
}
stop() {
this.stopped = true;
clearTimeout(this.timer);
}
}
const poller = new SmartPoller(async () => {
const res = await fetch('/api/job/123');
const data = await res.json();
if (data.status === 'complete') {
showResult(data);
return { done: true };
}
});
poller.start();
What this gives you:
- ✅ No request piling
- ✅ Exponential backoff on errors
- ✅ Pauses when tab is hidden
- ✅ Resets interval on success
- ✅ Clean start/stop API
When to use: Any user-facing polling — file uploads, job processing, real-time status.
3. AbortController + Cleanup (Bulletproof)
The production version also needs to cancel in-flight requests when you stop polling:
class Poller {
#controller = null;
#timer = null;
#active = false;
constructor(url, { interval = 2000, onData, onDone, onError }) {
this.url = url;
this.interval = interval;
this.onData = onData;
this.onDone = onDone;
this.onError = onError;
}
async #tick() {
this.#controller = new AbortController();
try {
const res = await fetch(this.url, {
signal: this.#controller.signal
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
this.onData?.(data);
if (data.done || data.status === 'complete') {
this.onDone?.(data);
this.stop();
return;
}
} catch (err) {
if (err.name === 'AbortError') return;
this.onError?.(err);
}
if (this.#active) {
this.#timer = setTimeout(() => this.#tick(), this.interval);
}
}
start() {
this.#active = true;
this.#tick();
}
stop() {
this.#active = false;
this.#controller?.abort();
clearTimeout(this.#timer);
}
}
const poll = new Poller('/api/render/abc123', {
interval: 2000,
onData: (d) => updateProgress(d.progress),
onDone: (d) => showImage(d.url),
onError: (e) => console.error('Poll error:', e)
});
poll.start();
poll.stop();
When to use: SPAs, React/Vue components where you MUST clean up on unmount.
The Nuclear Option: Don't Poll at All
If you control the backend, Server-Sent Events (SSE) are almost always better:
// Server (Node/Express)
app.get('/api/job/:id/stream', (req, res) => {
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
const interval = setInterval(async () => {
const job = await getJob(req.params.id);
res.write(`data: ${JSON.stringify(job)}\n\n`);
if (job.status === 'complete') {
clearInterval(interval);
res.end();
}
}, 1000);
req.on('close', () => clearInterval(interval));
});
// Client
const source = new EventSource('/api/job/123/stream');
source.onmessage = (e) => {
const data = JSON.parse(e.data);
updateProgress(data);
if (data.status === 'complete') {
source.close();
}
};
SSE auto-reconnects, uses less bandwidth, and pushes updates instantly. The only downside: slightly more server complexity.
TL;DR
| Pattern | Effort | Use When |
|---|---|---|
setInterval + lock |
5 min | Internal tools |
| Smart backoff + visibility | 15 min | User-facing features |
| AbortController class | 20 min | SPAs, complex UIs |
| SSE | 30 min | You control the backend |
The recursive setTimeout pattern is a code smell. Every time I see it in a PR, it's eventually a bug report. Save yourself the debugging — pick the right pattern upfront.
What polling patterns do you use? Drop a comment if you've got a better approach.
Top comments (0)