Building a live status dashboard on top of a public API

A weekend project: pull a public feed, cache it sensibly, and render a page that stays useful when the upstream service is having a bad day.

Rowan Vale··3 min read

A city near me publishes its bike-share availability as a JSON feed updated every thirty seconds. It is a nice size of problem: real data, no authentication, and a source you have no control over — which is the part that actually teaches you something.

Here is how I built a small dashboard on top of it, and the four decisions that mattered.

Never let the browser talk to the upstream directly

The obvious first version fetches the feed from the client on every page load. It works, and then it does not: the upstream sets no CORS headers you can rely on, has no rate limit you were told about, and goes down at exactly the wrong moment.

Put one endpoint of your own in front of it:

const CACHE_TTL = 30_000;
let cache = { at: 0, payload: null };

export async function getStations() {
  if (Date.now() - cache.at < CACHE_TTL && cache.payload) {
    return cache.payload;
  }

  const response = await fetch(UPSTREAM_URL, {
    signal: AbortSignal.timeout(4000),
  });
  if (!response.ok) throw new Error(`upstream ${response.status}`);

  cache = { at: Date.now(), payload: normalise(await response.json()) };
  return cache.payload;
}

Thirty seconds of cache turns a thousand visitors into two upstream requests a minute. It also means your page keeps working for half a minute after the feed falls over.

Normalise at the edge, once

Public feeds are shaped for whoever built them. Ours mixes naming conventions, sends numbers as strings, and marks a broken station by omitting a field rather than flagging it.

Translate it into your own shape at the boundary, and let nothing past that line know what the upstream looks like:

function normalise(raw) {
  return raw.data.stations.map((station) => ({
    id: String(station.station_id),
    name: station.name.trim(),
    bikes: Number(station.num_bikes_available ?? 0),
    docks: Number(station.num_docks_available ?? 0),
    reportedAt: new Date((station.last_reported ?? 0) * 1000),
    healthy: station.is_installed === 1 && station.is_renting === 1,
  }));
}

The day the feed renames a field, exactly one function changes.

Decide what “stale” looks like before you need it

Every dashboard eventually shows numbers that are wrong. The only question is whether it admits it.

I keep three states and render all three differently:

  • Live — the payload is under a minute old.
  • Stale — between one and ten minutes. Show the data, show the age.
  • Unavailable — older than ten minutes, or no payload at all. Show the last known figures greyed out, with the timestamp.
export function freshness(reportedAt, now = Date.now()) {
  const age = now - reportedAt.getTime();
  if (age < 60_000) return 'live';
  if (age < 600_000) return 'stale';
  return 'unavailable';
}

A dashboard that silently shows twenty-minute-old numbers is worse than one that shows nothing, because someone will act on it.

Poll politely, and stop when nobody is looking

Refreshing on an interval is fine. Refreshing on an interval in forty background tabs is not.

let timer = null;

function schedule() {
  clearTimeout(timer);
  if (document.hidden) return;
  timer = setTimeout(async () => {
    await refresh();
    schedule();
  }, 30_000);
}

document.addEventListener('visibilitychange', schedule);
schedule();

Two details in there earn their place: the timer is rescheduled after the refresh resolves, so a slow response cannot stack requests; and a hidden tab schedules nothing at all.

Render on the server, hydrate almost nothing

The page is a table. Tables do not need a framework.

I render the whole thing at request time and ship one small script that swaps in fresh numbers. When the script fails to load, the page is still correct as of page load — which is the entire point of starting from HTML.

The client-side portion is about thirty lines:

async function refresh() {
  const response = await fetch('/api/stations');
  if (!response.ok) return markUnavailable();

  const stations = await response.json();
  for (const station of stations) {
    const row = document.querySelector(`[data-station="${station.id}"]`);
    if (!row) continue;
    row.querySelector('[data-bikes]').textContent = station.bikes;
    row.querySelector('[data-docks]').textContent = station.docks;
    row.dataset.state = station.state;
  }
}

No diffing, no virtual anything. The server already decided what the page looks like; the script only edits numbers in place.

What I would do differently

Two things.

I would store a rolling history from the start. Availability at a single moment is mildly interesting; availability over a week is genuinely useful, and I threw away three months of it before realising.

And I would have written the “upstream is down” path first instead of last. It is the only path guaranteed to run in production, and it was the one I tested least.