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. This is 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, and 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 for global markets and a hands-on role in every architecture decision.
More articles by AmitYou might also like
We ship software that scales. Let's work together.