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.
What we wanted
- A view you saw in the last minute shows instantly, with no request.
- An older view shows instantly, faded, while fresh data loads. That's stale-while-revalidate.
- No duplicate requests for data that's already on its way.
- 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 }
}
inFlightremoves duplicates. The promise is stored before it resolves, so a second caller gets the same one.finallyclears it either way, so a failure isn't remembered.- A
Mapis 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,
getStatshands 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.