Your scores update. That's the whole job, right? Poll the API, patch the DOM, go home. Except "Final" sitting on a game that just went to overtime says otherwise, and last night StatPro was full of that lie.
Fixing it turned into the most interesting bug class I've hit in a while: time itself as a caching problem.
What a live score actually is
A live score is a derived fact with a shelf life. The score was 24-21 at 8:42 in the third; by the time your phone renders it, that fact might already be history. So you can't cache it like a standings table. But you also can't have a few thousand phones hammering the origin every second during a playoff game.
The shape we landed on for StatPro: two public snapshot endpoints, one per league scoreboard and one per game. They're cheap to serve because the edge caches each response for five seconds:
Cache-Control: public, s-maxage=5, stale-while-revalidate=10
One line. During a game, the first request after the cache expires pays the origin cost; everyone else inside that five-second window shares it. Between games the same endpoints just serve the last snapshot, which is why the mobile app can poll them around the clock without a special "game day mode."
The app polls every 30 seconds, but only when three things are true: a game in view is actually live, the screen is focused, and the app is foregrounded. A backgrounded phone sending heartbeat requests is how you earn battery-drain reviews and a rate-limited API in the same week.
The bug that is really a diagram
Once you have two clocks (the game and the poller) you have ordering problems. The nasty one: a poll fires, the response sits in a socket queue for a few seconds, a newer poll returns first, and then the stale one lands and overwrites it. Your game just went backwards. Q3 4:12 flips to Q3 5:01. Users screenshot that stuff.
Here is the whole bug in one picture. Two responses leave the same endpoint, one lags, and the merge is the only place left to enforce order:
graph TB
P["poll fires (t=0s)"] --> F["newer response (t=30s)"]
P --> S["stale response returns late"]
F --> M["merge into cache, timestamp wins"]
S --> M
M -->|"observedAt newer"| OK["clock moves forward"]
M -->|"observedAt older"| BAD["clock jumps BACKWARD"]
The fix is boring on purpose. Every snapshot carries an observedAt timestamp; the merge into the React Query cache compares it against what is already there and drops anything older. A late snapshot never moves a game backwards, no matter how weird the network gets.
Uncertainty deserves its own UI
The subtle design call was what to show when we don't know. The feed could stall: the provider lags, the poll fails, whatever. Showing a confident, wrong clock is worse than showing an honest one, so the label carries its own state. The game clock renders as-is while fresh. If no label arrives, or the data goes stale, it degrades to a plain "Live" chip in the live color. When the game ends, "Final". Each state is a claim about the world we can actually back.
What I keep coming back to: most "realtime" features are really timestamp discipline. The transport is rarely the hard part; the hard part is deciding, at the merge point, which fact wins. Arrival order is what UDP gave us in 1980 and it is still lying to UI engineers today. If your app shows anything time-shaped (scores, prices, delivery ETAs, "active now" dots), ask what your equivalent of observedAt is. If the answer is "nothing, last write wins," you have this bug, you just haven't seen it yet.
Related: the second commit of the night shrank our Ask answer pages from re-reading related questions on every answer render to once a minute per league. Same disease, milder case.