The 30-line cache that made our dashboard feel instant

People don't read analytics dashboards, they fidget with them. Last 30 days, last 7, back to 30 just to check. Every click used to bring up a spinner, even for numbers they'd seen five seconds earlier. Now going back is instant, and the whole cache fits on one screen.

30 days7 days30 daysGermanyNo filter first looknew viewrevisitnew viewrevisit Before 30 days: waits on the network 7 days: waits on the network 30 days again: waits on the network Germany: waits on the network No filter again: waits on the network waitwaitwaitwaitwait After 30 days: waits on the network 7 days: waits, with the previous numbers shown faded Germany: waits, with the previous numbers shown faded waitwaitwait 30 days again: instant, from the cache No filter again: instant, from the cache instantinstant Waiting on the network Shown instantly from the cache
Before: five waits. After: three, and both revisits are instant.

What we wanted

  1. A view you saw in the last minute shows instantly, with no request.
  2. An older view shows instantly, faded, while fresh data loads. That's stale-while-revalidate.
  3. No duplicate requests for data that's already on its way.
  4. Bounded memory, because somebody always leaves the tab open for a week.

Our stats endpoints are plain GETs, and the URL describes the whole answer, so the URL is the cache key.

The cache

const recent = new Map<string, { at: number; data: unknown }>()
const inFlight = new Map<string, Promise<unknown>>()
const RECENT_MAX = 200

/** GETs a stats path, sharing a request already in flight and remembering the answer. */
export function getStats<T>(path: string): Promise<T> {
  let p = inFlight.get(path)
  if (!p) {
    p = api<T>(path)
      .then((data) => {
        recent.delete(path)
        recent.set(path, { at: Date.now(), data })
        if (recent.size > RECENT_MAX) recent.delete(recent.keys().next().value!)
        return data
      })
      .finally(() => inFlight.delete(path))
    inFlight.set(path, p)
  }
  return p as Promise<T>
}

/** The remembered answer for a stats path, and how old it is in milliseconds. */
export function recentStats<T>(path: string): { data: T; age: number } | undefined {
  const r = recent.get(path)
  return r && { data: r.data as T, age: Date.now() - r.at }
}
  • inFlight removes duplicates. The promise is stored before it resolves, so a second caller gets the same one. finally clears it either way, so a failure isn't remembered.
  • A Map is nearly an LRU already. Maps keep keys in the order they were added, and deleting then setting a key moves it to the back. So the first key is always the oldest, and past 200 entries it goes. Strictly, that drops the least recently fetched entry, not the least recently read. It's close enough, and we checked.
  • Each entry knows its age, so callers decide what "fresh" means.

The hook

const STATS_FRESH_MS = 60_000

export function useStats<T>(path: string) {
  const [last, setLast] = useState<T>()
  const [error, setError] = useState<string>()
  const [loading, setLoading] = useState(true)
  useEffect(() => {
    let live = true
    setError(undefined)
    const hit = recentStats<T>(path)
    if (hit && hit.age < STATS_FRESH_MS) {
      setLast(hit.data)
      setLoading(false)
      return
    }
    setLoading(true)
    getStats<T>(path)
      .then((d) => live && setLast(d))
      .catch((e: unknown) => live && setError(errorMessage(e)))
      .finally(() => live && setLoading(false))
    return () => {
      live = false
    }
  }, [path])
  // Until this path's answer arrives, the previous one stays on screen, faded.
  const hit = recentStats<T>(path)
  return { data: hit?.data ?? last, error, loading: loading && !(hit && hit.age < STATS_FRESH_MS) }
}

The last line does the stale-while-revalidate part. If there's a remembered answer for this exact path, it shows on the very first render, and if it's fresh there's no loading state at all. Otherwise the previous path's data stays on screen, faded, until the new answer lands. Numbers changing in place read much better than a panel collapsing into a spinner and back. The live flag ignores answers for a path the user has already left, so a slow response can't overwrite a newer one.

Why not the browser's HTTP cache?

Cache-Control: max-age=60, stale-while-revalidate=300 gets close, but:

  • The UI can't tell stale from fresh, so it can't fade the old numbers.
  • Private data can end up on disk. Kept in memory, it's gone when the tab closes.
  • Prefetching needs the same store. A dashboard starts every panel's request before the panels mount, and when each panel arrives, getStats hands it the request that's already on its way. That took a whole round trip off opening a dashboard: We made our dashboard 35% faster without touching a single query.

Reach for a library when you need mutations that update the cache, refetching on focus, retries or pagination. That's where 30 lines become 3,000.

The result

Going back to a period, or removing a filter, is now instant: no request, no spinner. Try it on the demo. Flip between two periods and watch the Network tab do absolutely nothing.