Previous Article
Add a Live WooCommerce Cart Icon to Your Header (No Plugin)
28 August 2018
Refreshing part of a page on a timer — a live count, an order status, a dashboard tile — looks like a two-line job. The two-line version works fine on your machine and then misbehaves in production: requests pile up on slow connections, the tab keeps polling for hours in a background window, and one server error turns into a request every second forever.
Here is how to do it properly with no jQuery and no library.
<div id="live" aria-live="polite">Loading…</div>
const target = document.getElementById('live');
async function refresh() {
try {
const res = await fetch('/api/status', { headers: { Accept: 'application/json' } });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
target.textContent = data.message;
} catch (err) {
console.error('Refresh failed', err);
}
}
setInterval(refresh, 10000);
refresh();
That will work. But it has a flaw worth understanding before you ship it.
setInterval fires on a fixed schedule regardless of whether the previous request finished. On a slow connection requests overlap and can arrive out of order, so an older response overwrites a newer one.Three concrete problems:
setInterval keeps hammering it at exactly the same rate.Schedule the next run only after the current one finishes:
const target = document.getElementById('live');
const BASE_DELAY = 10_000;
const MAX_DELAY = 5 * 60_000;
let timer = null;
let controller = null;
let failures = 0;
let stopped = false;
async function poll() {
// Cancel any in-flight request before starting a new one.
controller?.abort();
controller = new AbortController();
try {
const res = await fetch('/api/status', {
signal: controller.signal,
headers: { Accept: 'application/json' },
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
render(data);
failures = 0; // success resets the backoff
} catch (err) {
if (err.name === 'AbortError') return; // we cancelled it deliberately
failures++;
console.error('Poll failed', err);
} finally {
if (!stopped) schedule();
}
}
function schedule() {
// Exponential backoff with jitter so many clients do not retry in lockstep.
const backoff = Math.min(BASE_DELAY * 2 ** failures, MAX_DELAY);
const jitter = Math.random() * 1000;
timer = setTimeout(poll, backoff + jitter);
}
function render(data) {
target.textContent = data.message;
}
function start() { stopped = false; poll(); }
function stop() { stopped = true; clearTimeout(timer); controller?.abort(); }
start();
The jitter matters at scale. Without it, if your server briefly goes down, every connected client retries at precisely the same moment and delivers a synchronised traffic spike the instant it recovers — the "thundering herd" problem.
A tab left open in the background will happily poll all night, burning battery and your server's capacity. The Page Visibility API fixes this in a few lines:
document.addEventListener('visibilitychange', () => {
if (document.hidden) {
stop();
} else {
failures = 0; // refresh immediately on return
start();
}
});
This is probably the highest-value addition on this page. On a dashboard people leave open in a background tab, it can eliminate the large majority of requests.
Clean up on navigation too, particularly in a single-page app where the component unmounts but your timer does not:
window.addEventListener('pagehide', stop);
Replacing a container's contents on every poll destroys and rebuilds DOM nodes, which loses focus, collapses text selection, and resets scroll position. If a user is mid-interaction, that is disruptive.
function render(data) {
// Only touch the DOM when the value actually changed.
if (target.dataset.value === String(data.value)) return;
target.dataset.value = String(data.value);
target.textContent = data.message;
}
You can also let the server tell you nothing changed, which saves the bandwidth entirely:
let etag = null;
const res = await fetch('/api/status', {
headers: etag ? { 'If-None-Match': etag } : {},
});
if (res.status === 304) return; // unchanged, nothing to do
etag = res.headers.get('ETag');
Content that changes on its own is invisible to a screen reader user unless you announce it. Wrap the region in a live region:
<div id="live" aria-live="polite" aria-atomic="true"></div>
Use polite, not assertive — assertive interrupts whatever is being read, which for a routine status update is hostile. And be sparing: a region that updates every ten seconds with an assertive setting makes the page unusable.
Polling every 10 seconds means an average 5-second delay and a request per client per interval whether or not anything changed. Past a certain point, push is better:
| Approach | Direction | Best for |
|---|---|---|
| Polling | Client pulls | Infrequent changes, simple infrastructure, no persistent connection |
| Server-Sent Events | Server pushes | One-way live updates — feeds, notifications, progress |
| WebSocket | Both ways | Chat, collaborative editing, anything genuinely bidirectional |
SSE is dramatically underused. It runs over ordinary HTTP, reconnects automatically, and is about five lines on the client:
const source = new EventSource('/api/stream');
source.addEventListener('message', (e) => render(JSON.parse(e.data)));
source.addEventListener('error', () => {
// EventSource reconnects on its own — no retry logic needed.
console.warn('Stream interrupted, reconnecting…');
});
If updates originate on the server and only flow one way, SSE is almost always a better fit than a polling loop.
setTimeout, not setInterval — never let requests overlap.AbortController.aria-live="polite".Advertisement
Written by
Amit VermaFounder / Senior Software Engineer
Amit leads engineering at Being Idea. With 15+ years building scalable software products across global markets, he drives architecture decisions and engineering culture across every engagement.
More articles by AmitYou might also like
We ship software that scales. Let's work together.