0

Caching a small league table in localStorage without showing stale data

I keep a tiny page for my own use that shows a football league table and the next few fixtures. The data comes from a JSON file that updates a few times a week, and most of the time I open the page on my phone on a bad connection. Loading the JSON every time was slow, and when the network dropped I got an empty table. So I added a cache in localStorage. The first version was wrong in an interesting way, which is why I'm writing this down.

The first version

The obvious approach: fetch, save, and on the next load read from storage first.

async function getTable() {
  const cached = localStorage.getItem('table');
  if (cached) return JSON.parse(cached);
  const res = await fetch('/data/table.json');
  const data = await res.json();
  localStorage.setItem('table', JSON.stringify(data));
  return data;
}

It was fast. It was also showing me a table from nine days earlier, because nothing ever told it to refresh. A cache with no expiry is just an old copy.

Adding a timestamp

The fix is to store when you saved the data and decide how old is too old. For a league table that changes after match days, six hours was plenty for me.

const MAX_AGE = 6 * 60 * 60 * 1000;

function readCache(key) {
  try {
    const raw = localStorage.getItem(key);
    if (!raw) return null;
    const { savedAt, data } = JSON.parse(raw);
    return { data, fresh: Date.now() - savedAt < MAX_AGE };
  } catch (e) {
    return null;
  }
}

function writeCache(key, data) {
  try {
    localStorage.setItem(key, JSON.stringify({ savedAt: Date.now(), data }));
  } catch (e) {
    // storage full or blocked (private mode on some phones), ignore
  }
}

The try/catch matters more than I expected. Safari in private mode used to throw on setItem, and a corrupted value from an older version of the page would break JSON.parse and take the whole page down with it.

Show old data, then replace it

The part that made the biggest difference on a slow connection was not blocking on the network when I already have something to show. Render the cached copy straight away, even if it's stale, then fetch in the background and re-render when the new data arrives.

async function loadTable(render) {
  const cached = readCache('table');
  if (cached) render(cached.data, { stale: !cached.fresh });
  if (cached && cached.fresh) return;

  try {
    const res = await fetch('/data/table.json', { cache: 'no-store' });
    if (!res.ok) throw new Error(res.status);
    const data = await res.json();
    writeCache('table', data);
    render(data, { stale: false });
  } catch (e) {
    if (!cached) render(null, { error: true });
  }
}

When stale is true I grey out the table slightly and add a small "updated …" line with the saved time. That way I know if I'm looking at yesterday's numbers.

Things I'd do differently

Version the cache key (table:v2) whenever the JSON shape changes. I didn't, and an old phone kept rendering a column that no longer existed.

Keep the data small. localStorage is synchronous and limited to a few megabytes per origin, so it's fine for a table of sixteen rows but not for match histories. For anything bigger I'd move to IndexedDB, or let a service worker handle it with a stale-while-revalidate strategy.

And store times in UTC. The fixtures file has kick-off times in UTC and I convert them only when rendering, otherwise a cached copy saved before a daylight-saving change somewhere in the data ends up an hour off.


All rights reserved

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí