The problem
Live cricket scores update fast — sometimes multiple events land in the same animation frame. Wire that straight into setState and you get wasted renders, visible jank, and a UI that quietly falls behind the real match. Most sports scoreboard code I found on GitHub doesn't handle this at all — it just re-renders on every socket message and hopes for the best.
I wanted to see what it actually takes to build one that doesn't do that.
What it does differently
Batches, doesn't just receive. Every incoming WebSocket packet gets pushed into a buffer, and a single requestAnimationFrame callback flushes the whole buffer into one state commit — right before the browser paints. Ten events arriving in the same frame become one render, not ten.
De-duplicates by ID. Reconnects happen. Servers replay events. A processedIds set (capped and FIFO-evicted so it can't leak memory) means a re-sent packet never gets applied twice — checked at push-time, not flush-time, so even two duplicates arriving in the same frame before a flush can't slip through.
Never trusts the feed blindly. A malformed packet gets caught and skipped, not left to crash the whole batch. Once an innings ends — 10 wickets, the overs limit, or a chased-down target — the ingestion engine ignores every further packet, so a feed that keeps sending late events after a match is over can't reopen it.
Gets the actual cricket rules right. This was the part that took longer than expected: standard 2.3-overs notation (not a decimal fraction — 2 completed overs, 3 balls into the next), a full second-innings target chase where reaching the target ends the match immediately, even mid-over, and an accurate result — win by runs, win by wickets, or a tie — computed from the actual match state rather than guessed.
Doesn't own anything it doesn't have to. It never opens the WebSocket connection — you bring your own socket, your own reconnect logic, your own auth. It never calls an AI provider either: there's a generateSummary prop that accepts any function (sync or async) and just renders whatever text comes back. Same philosophy both times — accept a result, don't own the mechanism.
Isolated by construction. Each <LiveCricketScorecard> gets its own store via React Context, created once with a lazy useState initializer (not useMemo, which turned out to have a real bug: it silently reset the entire match on every parent re-render if the caller didn't memoize initialData — which is the default case for most usage). Two scorecards on one page genuinely can't interfere with each other.
What's in it
- rAF-batched ingestion with dedup and malformed-payload resilience
- 10-wicket cap, configurable overs limit, full second-innings target chase
- An
onInningsCompletecallback and an AI-extensibility hook (generateSummary) -
aria-liveregions for ball-by-ball updates and match results - 13 tests covering the batching, dedup, and cricket-rules logic
- TypeScript, ESM + UMD builds, zero required CSS beyond Tailwind
Try it
npm install react-sports-scorecard
- Live demo — a simulated feed, a second-match spawner to see the isolation directly, and a real target chase you can play through
-
GitHub — the ingestion engine specifically is in
src/ingestion/socketIngestion.tsif you want to see how the batching/dedup logic works - npm
Cricket-only for now, MIT licensed. Issues and PRs welcome — I'm particularly interested in what breaks under a real production feed, since everything here has only been tested against a simulated one.
Top comments (1)
Going to do a deeper dive into the architecture and rAF batching logic on LinkedIn tomorrow!