Caching live sports scores when the data changes every ball

Live cricket scores are an awkward caching problem. During a match, traffic is roughly forty times the baseline, and the thing everyone is requesting changes every few seconds.
Cache too long and you show a stale score, which is the one thing users will not tolerate. Do not cache and the upstream data provider — who bills per request and rate-limits — cuts you off.
Split the payload by how fast it changes
The key move, and the one that took me longest to see. A match page looks like one object but it is really three, with completely different volatility:
- Static. Teams, venue, squad lists, tournament. Changes never. Cache for hours.
- Slow. Scorecard totals, wickets, partnerships. Changes every few minutes.
- Volatile. Current score, over, striker. Changes every ball.
Originally I cached the assembled response for 5 seconds. That meant re-fetching squad lists twelve times a minute for data that had not changed since the toss.
Split apart: static gets a 6-hour TTL, slow gets 60 seconds, volatile gets 5. Upstream requests dropped by about 85% with no change in freshness where it mattered.
Stampedes are the real failure mode
With a 5-second TTL and heavy concurrent traffic, expiry is a cliff. The key vanishes, every in-flight request misses simultaneously, and all of them hit the upstream API at once. The provider rate-limits you, requests fail, the cache stays empty, and it repeats.
The fix is to never let a request wait on a refresh. Serve what you have, refresh behind it:
const cached = await redis.get(key);
if (cached) {
const { value, freshUntil } = JSON.parse(cached);
if (Date.now() > freshUntil) {
// One refresher only; everyone else keeps the stale value.
if (await redis.set(lockKey, '1', 'NX', 'EX', 10)) {
refreshInBackground(key).catch(logger.error);
}
}
return value;
}
Two TTLs: a logical freshness deadline stored in the value, and a much longer physical expiry on the key. The physical TTL exists purely so a dead key eventually disappears. Users never wait for a refresh, and exactly one process talks upstream.
The
NXlock is the important line. Without it, every request that notices staleness starts its own refresh and you have rebuilt the stampede with extra steps.
Push, do not poll, for the last hop
Even at 5 seconds, thousands of clients polling is a lot of requests that mostly return unchanged data.
Scores now go out over a WebSocket. One process consumes upstream, writes to Redis, and publishes deltas to subscribed clients. Clients poll only as a fallback when the socket drops.
Deltas, not full payloads. A full match object is around 40KB; a score change is under 200 bytes. At ten thousand concurrent viewers that difference is the entire bandwidth bill.
Cache negative results too
A subtle one. When a match id does not exist — a bad link, a bot walking the id space — that request goes upstream every time because there is nothing to cache.
Cache the miss, with a short TTL, say 30 seconds. It turns an unbounded stream of upstream requests into two per minute.
What I got wrong
I ran the cache without a circuit breaker for the first season. When the upstream provider had a slow period — not down, just responding in 8 seconds — refreshes piled up, connections exhausted, and the whole thing fell over despite most of the data being perfectly serviceable from cache.
Now a refresh that fails three times consecutively stops trying for a minute and the stale value keeps serving. A score that is two minutes old is a far better outcome than a page that does not load.