Your Build Board Doesn't Need a Heartbeat: Polling CI Without the Guilt
Photo: Courtesy of NIAID Ryan Kissinger, Public domain, via Wikimedia Commons
Every small UK team eventually gets one: a build-status page pinned to a spare monitor, a tab in the office, or a browser window on someone's second screen. It shows whether main is green. Then someone sets it to refresh every ten seconds and the CI provider starts sending polite emails about API usage. The dashboard is supposed to reduce anxiety, not create a new job.
The fix isn't a fancier dashboard. It's a loader that behaves like a sensible colleague: it checks the cache first, looks only when someone is looking, and backs off when the API is having a bad day. Here's a pattern we've used for a small UK team with two repos, one shared runner and a budget that doesn't stretch to an enterprise CI plan.
The ten-second heartbeat is a trap
Polling every ten seconds feels responsible. It isn't. A CI status endpoint is not a stock ticker. Builds take minutes. Nothing useful changes in the time it takes to boil the kettle. Meanwhile, a browser tab left open over lunch can fire hundreds of requests, and a laptop that sleeps and wakes can fire a burst all at once. Multiply that by every developer who leaves the board open and you've got a rate-limit incident dressed up as diligence.
The other problem is synchronisation. If everyone's dashboard starts at 9am and polls on the same interval, the provider sees a little spike every ten seconds. Jitter is not a luxury; it's basic manners.
Cache first, then ask nicely
The first version of any status loader should render something immediately from cache. Store the last successful response, the ETag if the provider gives you one, and the time it was fetched. On page load, show that status with a clear timestamp: Last checked 09:42. Then revalidate in the background.
ETag checks are the cheapest trick here. Send If-None-Match: <etag> and a good API returns 304 Not Modified with no body. You still make a request, but you don't pay for the payload, and you don't have to re-render. Some CI APIs don't expose ETags on status endpoints; if not, fall back to a short cache TTL and a conditional request using If-Modified-Since if available. If neither exists, keep the cache and accept that you'll fetch a small JSON blob occasionally. The point is to stop treating every poll as a fresh discovery.
Never put a CI token in the browser. Put a tiny server-side proxy in front of the provider, keep the token there, and have the proxy pass through the ETag. That also gives you one place to add a cache and one place to log rate-limit responses.
Look away when nobody's watching
The Page Visibility API is the simplest win. When document.hidden is true, stop the timer. When the tab becomes visible again, check once, then restart the schedule. This handles lunch breaks, meetings, and the tab someone forgot about on Friday. It also stops a sleeping laptop from waking up and hammering the API.
If the dashboard lives on a wall-mounted TV, visibility never changes. That's fine: treat it as a special case. Use a longer base interval, maybe two or three minutes, and make the stale timestamp obvious. A build board that is two minutes behind is still useful. A build board that gets the team rate-limited is not.
Also listen for online and offline. If the office Wi-Fi drops, don't queue up a dozen retries. Wait for the connection to come back, then do one check.
Back off with a bit of jitter
Every loader needs a failure path. A 500 from the CI provider is not a reason to retry every ten seconds. A 429 is definitely not. Use exponential backoff with a cap, and respect Retry-After when it's present.
A simple schedule: start at 60 seconds, double on failure, cap at 10 minutes, reset on success. Add plus-or-minus 20% jitter so a dozen dashboards don't line up. If you get a 429, jump straight to the cap and show a calm message: Status delayed — provider asked us to wait. No red banners. No alarm sounds. It's a build board, not a fire alarm.
A small loader you can actually read
The core is less than forty lines. The exact endpoint will depend on your CI provider, but the shape is the same.
const KEY = 'ci-status:v1';
const BASE = 60_000;
let timer = null;
let failures = 0;
function readCache() {
try { return JSON.parse(localStorage.getItem(KEY)); } catch { return null; }
}
function writeCache(data, etag) {
localStorage.setItem(KEY, JSON.stringify({ data, etag, at: Date.now() }));
}
async function check() {
if (document.hidden) return;
const cached = readCache();
const headers = {};
if (cached?.etag) headers['If-None-Match'] = cached.etag;
try {
const res = await fetch('/api/ci-status', { headers, cache: 'no-store' });
if (res.status === 304 && cached) {
failures = 0;
render(cached.data, cached.at);
} else if (res.status === 429) {
failures++;
render(cached?.data, cached?.at, 'delayed');
} else if (!res.ok) {
throw new Error(`CI returned ${res.status}`);
} else {
const data = await res.json();
writeCache(data, res.headers.get('ETag'));
failures = 0;
render(data, Date.now());
}
} catch (err) {
failures++;
render(cached?.data, cached?.at, 'stale');
} finally {
schedule();
}
}
function schedule() {
clearTimeout(timer);
const base = Math.min(BASE * 2 ** failures, 600_000);
const jitter = base * (0.8 + Math.random() * 0.4);
timer = setTimeout(check, jitter);
}
document.addEventListener('visibilitychange', () => {
if (!document.hidden) check();
});
check();
That's the whole idea. The render function is yours: show the branch, the commit, the person who broke it, and a timestamp. Be honest about staleness. A grey dot and Last checked 09:42 is better than a green dot that might be a lie.
The dashboard should be boring
A build-status board earns its place by being glanceable and quiet. It should not be the reason your team hits a rate limit, and it should not wake a laptop in a meeting. Cache the last good answer, revalidate with ETags, pause when hidden, jitter the schedule, and back off when the provider says no. Then go and look at the actual build. That's the point.