Design a live cricket score page for 50 million fans
A live cricket score page that tens of millions of phones open in the same minute: one scorer writes each ball, everyone reads the same bytes. The hard parts are the load shape (a herd at the toss, every wicket and the last over), choosing between CDN-cached polling and open streams with real numbers, a ball-by-ball event log with sequence numbers and corrections, a client that never shows the score going backwards by mistake, a server-rendered page with small live islands, and cheap phones on flaky 3G.
The question
Design the live score page for a big cricket match, like Cricbuzz or ESPNcricinfo during an India vs Pakistan World Cup game. It shows the current score, ball-by-ball commentary, the full scorecard and a few graphs, and it updates within a few seconds of each ball.
Assume 50 million people open it during the match, mostly on Android phones in India, many on slow or flaky mobile data, and most of them arrive in the same minute when a wicket falls. Walk me through how the score reaches them, what the page looks like, and how it stays correct and cheap.
Real-life examples: Cricbuzz match page, ESPNcricinfo live scorecard, JioHotstar scorecard next to the stream, Google's live score card for an India vs Pakistan match, BBC Sport live cricket
Requirements: one match, fifty million phones
A live score page is the page you open to follow a match when you can't watch it: the score at the top (IND 187/4 (32.3)), the batters and bowler on strike, ball-by-ball commentary (one short line per delivery), the full scorecard (every batter's runs and every bowler's figures), and a few graphs. Cricbuzz and ESPNcricinfo are the classic ones. JioHotstar shows a scorecard next to its video, and Google shows a live card right in the search results.
Several neighbours already cover pieces of this, and we link them instead of repeating them. The live-leaderboard guide covers fanning out ranked updates, and the stock-ticker guide covers many numeric updates per second with throttled rendering. The live-comments and live-streaming guides cover chat and video during a live event. The multiplayer-trivia guide covers moments that must be in sync for many players.
The wikipedia guide covers edge caching for anonymous readers, the notifications-center guide covers alerts and their settings, and the infinite-scroll guide covers long lists that load as you scroll. This guide is about what is special here: a read-only page, read by tens of millions of people at the same second, updated by one source, on cheap phones and bad networks.
Start with questions. Each answer moves the design. The middle column is what this guide assumes.
| Question | Answer we assume | What it changes |
|---|---|---|
| How many people, and when? | Up to 50 million readers across the match, 10 to 25 million on the page at the peak minute | Every request must be served from a CDN edge, not from our servers |
| How fresh must the score be? | About 4 seconds after the scorer enters the ball on average, and under 8 seconds for almost everyone; an online video stream is often 10 to 40 seconds behind the stadium | We can cache for 1 to 2 seconds and still beat most video streams |
| Who writes the data? | One official scorer per match, through an internal scoring tool | One writer, millions of identical readers: a broadcast |
| Are readers signed in? | Mostly not; the page is the same for everyone | The same bytes for all, so the CDN can cache them |
| Which devices and networks? | Mostly Android phones with 2 to 4 GB of RAM, on 4G that often drops to 3G speeds in a crowded stadium or on a train | A tiny JavaScript budget and payloads of a few hundred bytes |
| Which languages? | English, Hindi, Tamil, Bengali, Telugu and more for commentary | Commentary is per language; the score itself is numbers |
| Is there video? | Not on this page; streaming rights belong to the broadcaster | No video player; we must avoid spoiling people who watch a delayed stream |
| Alerts? | Yes, for fans who follow a team: wickets, fifties, the result | Web push, sent by the server, with rate limits |
Functional requirements
- A match page shows the score, overs, run rate, required rate in a chase, the two batters, the bowler, and the last six balls (
1 4 W 0 6 1). - Ball-by-ball commentary, newest on top, with over summaries, and the full history of the match on scroll.
- A full scorecard per innings: batting, bowling, extras, fall of wickets, partnerships.
- Two classic graphs: the worm (cumulative runs per over for both teams) and the manhattan (runs in each over as bars).
- Corrections are visible: if a wicket is overturned on review, the page shows it was overturned, not a silent jump.
- Follow a team or a match and get a push notification for wickets and the result.
Non-functional requirements
- Scale: 10 to 25 million concurrent readers at the peak, arriving within the same 30 to 60 seconds.
- Freshness: a new ball visible about 4 seconds after the scorer's entry on average, and within 8 seconds on 95% of phones.
- First view: the current score in the first HTML response, readable in under 2 seconds on a slow 4G phone; Largest Contentful Paint under 2.5 seconds.
- Cost per reader: an update costs a few hundred bytes, not a full page; mobile data costs real money for many readers.
- Correctness: the score never goes backwards on screen unless a real correction happened, and then it says so.
- Resilience: if our servers struggle, readers see a slightly older score, never an error page.
- Accessibility and language: a screen reader user can follow wickets without being flooded; commentary in several Indian languages.
Out of scope
The scorer's tool, video streaming, fantasy games and betting odds are out. So are comments and chat (see live-comments). We will touch push notifications only where they differ from the notifications-center guide.
Check your understanding · Exercise 1
Which answers to the scoping questions change the design the most? Select all that apply.
What makes this hard: a quiet page with violent spikes
Cricket traffic in India is unlike anything else on the web, and the streaming apps have published their records. Hotstar (now JioHotstar) reported 25.3 million people watching at once during the India vs New Zealand World Cup semi-final in July 2019. Its successor Disney+ Hotstar reported 59 million (5.9 crore) at once during the India vs Australia World Cup final in November 2023, and JioCinema reported more than 32 million (3.2 crore) during the IPL final in May 2023.
These are the companies' own figures for video viewers, not score page readers. Still, they show the size of the crowd that follows one match at one moment, and score pages see that crowd plus everyone who can't watch.
Five properties of this problem drive every later decision. Keep them in mind; each chapter comes back to them.
Five properties
- One writer, millions of readers. The official scorer enters each ball. Nobody else writes. Every reader wants exactly the same answer to the same question: what is the score now?
- The data is tiny and changes slowly. The live score is about 300 bytes of JSON. A ball is bowled roughly every 40 seconds (a T20 innings of 120 balls takes about 85 minutes). So between two balls, nothing changes for 40 seconds, then everything changes at once.
- Traffic is a heartbeat with spikes. Readers don't arrive smoothly. They arrive at the toss, at the start, at every wicket, and in the last over. A wicket triggers push alerts and WhatsApp messages, and a few million people open the page within the same 30 seconds.
- The history is ordered and append-only, but can be corrected. Balls happen in order. Yet the third umpire can overturn an out, and a scorer can fix a wrong entry five balls later.
- The readers are on cheap phones and bad networks. A mid-range Android phone, 4G that falls to 3G speeds in a crowd, data packs that people pay for by the gigabyte, and the tab often in the background.
The figure shows the shape. The baseline grows through the match, and every wicket adds a spike that is several times the baseline for a minute, then decays. The last over of a close chase is the highest point of the day. Capacity planning is about the spikes, not the average.
Back of the envelope
Let's put numbers on the peak minute, because they decide the delivery design in the next chapters. These are estimates for planning, not published figures from any one app.
| Quantity | Estimate | How we got it |
|---|---|---|
| Readers on the page at the peak | 20 million | Below the 59 million video peak; many watchers don't also open a score page |
| Poll interval while live | about 5 seconds | Fresh enough: a ball every 40 seconds, and video streams run 10 to 40 seconds behind |
| Requests per second at the edge | about 4 million | 20 million / 5 s |
| Size of one score response | about 1 KB on the wire when the score changed; about 150 bytes for a 304 Not Modified | About 600 bytes of compressed JSON (the score and a short tail of recent events) plus response headers |
| Times the score actually changes | about once every 40 seconds | One ball per 40 s, plus a few corrections |
| Polls that see no change | about 87% | With a 5 s poll and a 40 s gap between balls, only 1 poll in 8 sees a new ball |
| Edge bandwidth | about 1 GB/s, roughly 8 Gbit/s | 4 million x (1 in 8 at 1 KB + 7 in 8 at 150 bytes) |
Two numbers matter most. First, 4 million requests per second is far too many for our own servers, but it's normal work for a large CDN, which is built for exactly this. Second, most requests ask a question whose answer hasn't changed. That means the answer can be cached, and a reader who already has it can be told "nothing new" in a few bytes.
Think first
Why does a wicket make traffic spike, when the readers already on the page were polling anyway?
Readers already on the page don't add load: they keep polling at the same rate. The spike comes from new readers. A wicket sends push alerts, people message each other, and people who were working open the app. All of them load the full page, its HTML, CSS and JavaScript, not just the 300 byte score.
So the spike is mostly first loads, and the page shell must be as cacheable as the score. A spike of 3 million first loads in 30 seconds is 100,000 page loads per second on top of the polls.
Check your understanding · Exercise 2
A ball is bowled about every 40 seconds and readers poll every 5 seconds. What follows? Select all that apply.
Delivery: the score is a broadcast, not a conversation
Many real-time designs are conversations: a chat, a shared document, a multiplayer game. Many people write, and each reader sees a slightly different, personal view (their unread count, their cursor, their own message pending). Those designs need a connection per user, because the server must send each user something different.
A score page is a broadcast. One scorer writes, twenty million readers ask the same question, and at any second the right answer is the same bytes for all of them. That one fact is the key to the whole design: if the answer is identical, it can be computed once and copied, and copying bytes is what CDNs do best.
A CDN (content delivery network) is a set of servers in hundreds of cities, called edges, that keep copies of responses close to readers. The cdn-caching lesson covers how they work.
The danger is that a broadcast quietly turns into a conversation through small, harmless-looking choices. Each one makes the response different per reader, and then the CDN can't share it.
- A
Set-Cookieheader on the score response, for example from an analytics or session middleware. Many CDNs won't cache a response that sets a cookie. - A personal field in the JSON:
isFollowing: true, the reader's name, an ad slot chosen per user. - A cache-busting query string such as
?t=1730000000123, added by a developer who once saw a stale score. Every URL is unique, so every request misses the cache. Cache-Control: privateorno-storecopied from the API's default headers.- A
Varyheader on something with many values, such asVary: CookieorVary: User-Agent. The CDN then stores one copy per cookie or per browser string, which is nearly one per reader.
Decision Where do the personal parts of the page go (following a team, language, signed-in state)?
What we pick: Shared score, separate personal call. The score is the one thing everyone needs, so it must be the cheapest possible thing to serve. The few truly personal bits change rarely and belong in client state. Language is a special case: it has a handful of values, not millions, so it goes in the path of the commentary files (/live/hi/m/4521/recent.json) and the CDN keeps a few copies. Serve the shared paths from a cookie-free hostname so no middleware can attach a cookie by accident.
Split the data by how often it changes
A match page shows several kinds of data, and they change at very different speeds. If we put all of it in one big response, the whole thing must be refetched whenever the fastest part changes. So we split it into resources by change rate, each with its own cache lifetime.
| Resource | Example URL | Size (compressed) | Changes | Edge cache lifetime |
|---|---|---|---|---|
| Live summary: score, batters, bowler, last 6 balls, sequence number, a short tail of recent events | /live/m/4521.json | about 600 B | Every ball | 1 to 2 s |
| Recent commentary: the last 2 overs | /live/en/m/4521/recent.json | about 2 KB | Every ball | 1 to 2 s |
| Scorecard of the current innings | /live/m/4521/card/2.json | about 4 KB | Every ball, but read less often | 5 s |
| A completed over's commentary | /m/4521/en/over/2-17.json?v=3 | about 1.5 KB | Almost never; only on a correction, which bumps v | 1 day, immutable |
| Match facts: teams, venue, squads | /m/4521/info.json | about 3 KB | Once or twice per match | 60 s |
| Page shell: HTML, CSS, JavaScript | /_next/static/... | about 60 KB | Per deploy | 1 year, hashed file names |
The most useful row is the completed over. Once an over is finished, its commentary is history, so it can be cached for a day with immutable and scrolling back through 40 overs costs our origin nothing. If a correction ever changes an old over, we bump its version, and the live summary tells clients the new version. The http-caching lesson covers versioned URLs and immutable.
Check your understanding · Exercise 3
Which of these would quietly stop the CDN from sharing the live score between readers? Select all that apply.
Getting each ball to 20 million phones
Now the big decision: how does a new ball travel from the scorer to the phones? The realtime lessons (short-polling, long-polling, server-sent-events, websockets and choosing-realtime) explain each transport. Here we weigh them for this one, unusual load: 20 million identical readers, one change every 40 seconds, phones on flaky networks.
The naive answer in an interview is "real-time, so WebSockets". Picture what that means at the peak: twenty million TCP and TLS connections stay open for three hours. If one server holds about 200,000 mostly idle connections (an estimate; the real number depends on memory per connection and tuning), that is 100 servers doing nothing but holding sockets, and on a wicket they must write 20 million messages within a second or two.
Failures are worse. When one server restarts, its 200,000 phones reconnect at the same instant. And mobile networks drop idle connections often, so phones keep reconnecting all match long.
Decision How should the live score reach the readers?
What we pick: Short polling a CDN-cached JSON, with jitter and an interval that follows the match state. The data changes every 40 seconds and readers accept a few seconds of delay, so a 1 to 2 second edge cache costs little freshness and turns 4 million requests per second into a few at the origin. Polling is not automatically cheaper: at pay-as-you-go list prices (about $1 per million requests), 4 million requests per second cost around $14,000 an hour, far more than the servers of a stream tier. Buyers at this scale negotiate contracts that price requests much lower or bill mainly for data, and then the two bills are close. What polling buys is a design with no connection state to lose, spike capacity that already exists on the night, and failure that looks like a slightly older score, which is why polling a cached endpoint is a common choice for public score pages. Streams win when updates are frequent, personal, or must arrive in under a second (the stock-ticker and live-comments guides), and they stay a good choice for a smaller audience. Push is for wickets and results only, for readers who opted in.
How fresh is polling, really?
Follow one ball. The scorer enters a wicket at time 0, and the origin publishes the new summary within about 200 ms. An edge copy that was fetched just before that will live for up to its cache lifetime, say 2 seconds. A reader polls every 5 seconds, so on average they ask 2.5 seconds after the edge has the new copy, plus about 300 ms of mobile network time.
So the average reader sees the wicket about 3 to 4 seconds after the scorer. The unluckiest see it about 8 seconds after, because jitter can stretch one wait to 6 seconds. Broadcast TV usually runs a few seconds behind the stadium, and an online stream often 10 to 40 seconds behind, so the score page is roughly level with TV and well ahead of the stream.
The lab below lets you try the numbers. Pick the number of readers, the poll interval, the edge cache lifetime and a transport, and see the load at the edge and at the origin, the open connections, the freshness, the data each reader spends, and a rough hourly cost. Then press "Wicket!" to add a spike, with and without request collapsing and jitter.
Read the cost line with care, because it rests on assumed prices. The lab charges about $1 per million CDN requests (close to pay-as-you-go list prices, which are higher in some regions such as India), $0.05 per GB of data (a high-volume price), and $1 an hour for a server that holds 200,000 connections, with twice the servers needed for headroom.
At those list prices, polling 20 million fans every 5 seconds costs about $14,600 an hour, almost all of it request fees, against about $290 for streams. Turn the request fee off, as a negotiated or flat-rate contract might, and polling drops to under $200. The honest summary: the bill depends mostly on how your CDN contract prices requests, and the poll interval is the dial that controls it.
Think first
Why not long polling, which also avoids empty responses?
A long poll holds each request open until something changes. That means 20 million requests held open on our servers, the same cost as a stream, and none of them can be answered from a CDN cache because each waits for a future event.
Long polling suits a few thousand readers on old networks that block streams. At this scale, cached short polls do the job of keeping requests cheap far better.
Check your understanding · Exercise 4
In which situations would you move from cached polling to a stream (SSE or WebSocket)? Select all that apply.
Making the edge absorb the spike
Polling only works if the CDN really does absorb the load. Four mechanisms do that work together: a short cache lifetime, request collapsing, stale-while-revalidate, and an origin shield. Then two client habits finish the job: conditional requests and jitter. The dns-cdn-edge and cdn-caching lessons explain the basics; here we tune them for a score.
Decision How long should an edge keep the live summary?
What we pick: A 1 to 2 second edge lifetime with stale-while-revalidate, request collapsing and an origin shield. The origin load becomes a small constant (a few requests per second per region) that doesn't grow with the crowd, and a wicket spike changes nothing for the origin. Purging is tempting but it synchronizes every edge to miss at once, which is the herd we are trying to avoid.
Request collapsing and the shield
Picture one busy edge in Mumbai receiving 20,000 polls per second for match 4521. Its copy expires. Without request collapsing, every poll that arrives before the fresh copy comes back is also a miss: if the origin takes 200 ms, that is 4,000 identical requests from one edge, and there are hundreds of edges.
With collapsing, the edge sends one request and makes the other 3,999 wait for its answer. Fastly and CloudFront call it request collapsing, Varnish calls it request coalescing, and other large CDNs do something similar. Stale-while-revalidate goes one step further: the waiting polls don't wait at all, because the edge answers them with the expired copy while the single refresh runs.
An origin shield (also called a tiered cache) adds one more layer: edges don't ask the origin, they ask a regional cache, and that cache collapses again. Three hundred edges each refreshing every 2 seconds is 150 requests per second to the shields, and each of about 10 shield regions sends about 1 request every 2 seconds to the origin. Our origin, for the busiest match in the world, serves single-digit requests per second for the live summary.
stale-while-revalidate answers at once while a single collapsed refresh runs. The origin sees one request per region, however many phones are polling.GET /live/m/4521.json HTTP/2
Host: live.cdn.example.com
If-None-Match: "m4521-s217"
HTTP/2 304 Not Modified
ETag: "m4521-s217"
Cache-Control: public, max-age=1, s-maxage=2
CDN-Cache-Control: max-age=2, stale-while-revalidate=4, stale-if-error=600
Age: 1
Why two caching headers? Cache-Control is read by every cache, including the phone's own, and browsers such as Chrome (since version 75) honour stale-while-revalidate there. A poll that comes 4 seconds later would then get the phone's old copy while the refresh runs in the background, one whole poll late. CDN-Cache-Control (RFC 9213) carries rules only CDNs read; on a CDN that doesn't support it, set the same rules in its configuration.
You don't write the If-None-Match code yourself. The response carries an ETag and max-age=1, so the browser keeps it in its HTTP cache, and on the next poll fetch revalidates it automatically, gets a 304 with no body, and hands your code the cached 200 response. Your code compares the sequence number and skips rendering if it hasn't moved. A 304 is about 150 bytes on the wire instead of about 1 KB, and seven polls out of eight are 304s.
Jitter: breaking the waves
Here is a subtle failure. A wicket push alert goes out at 20:14:00, and three million phones open the page within the same two seconds and start polling every 5 seconds. They are now in step: every 5 seconds, a wave of 3 million requests, then near silence. Waves are much harder on every layer than the same load spread out, and they never fade by themselves.
The fix is jitter: each wait is the base interval times a random factor between 0.8 and 1.2. After a few polls the waves smear into a flat line. The retries-and-backoff lesson uses the same trick for retries.
async function pollLoop(matchId: string, signal: AbortSignal) {
let waitMs = 5000;
while (!signal.aborted) {
try {
const res = await fetch(`/live/m/${matchId}.json`, {
signal: AbortSignal.any([signal, AbortSignal.timeout(8000)]),
});
if (res.ok) {
const summary: LiveSummary = await res.json();
store.applyControl(summary.flags); // always, even for an old seq
store.applySummary(summary); // ignores older sequence numbers
waitMs = summary.pollMs; // the server decides the pace
} else {
waitMs = Math.min(waitMs * 2, 60_000); // back off on 5xx
}
} catch {
if (signal.aborted) return;
waitMs = Math.min(waitMs * 2, 60_000); // offline or timed out
}
const jitter = 0.8 + Math.random() * 0.4;
await sleep(waitMs * jitter, signal);
}
}
Let the match state set the pace
Polling every 5 seconds during a 45 minute rain delay wastes 540 requests per reader. So the live summary includes pollMs, chosen by the server from the match state. That gives operations one dial for the whole audience: during an emergency, raise pollMs to 15 seconds and the edge load drops by two thirds within one poll cycle, no deploy needed.
| Match state | pollMs | Why |
|---|---|---|
| Before the toss | 60,000 | Only the toss and team news can change |
| Ball in play, normal overs | 5,000 | A ball every 40 seconds; 5 s keeps us ahead of video streams |
| Final 2 overs of a close chase, or a review in progress | 3,000 | Every ball matters and readers are watching the screen |
| Innings break (10 to 20 minutes in a T20) | 30,000 | Nothing changes until the restart |
| Rain delay or bad light | 60,000 | Updates are rare text notes |
| Match finished | 300,000, then stop | Only late corrections and awards |
| Overload switch on | 15,000 or more | The operator's emergency dial |
Check your understanding · Exercise 5
A big edge gets 20,000 polls per second for one match, and the origin takes 200 ms to answer. Which statements are true? Select all that apply.
The data model: a match is a log of balls
Everything on the page, the score, the scorecard, the commentary and the graphs, can be computed from one thing: the ordered list of deliveries. So the source of truth is an append-only event log per match. Each event gets a sequence number (seq), an integer that goes up by one for every event in the match. The scorer's tool writes events; the server folds them into the views readers need.
Cricket has one trap that catches people who model it naively. The over notation 32.3 means the third legal ball of the 33rd over, but a wide or a no-ball is not a legal ball, so one over can have seven or eight deliveries and two of them can both be 32.3. A delivery therefore needs its own identity (seq) separate from its over and legal ball number. Using over.ball as the key would make the second 32.3 overwrite the first.
type Seq = number; // +1 per event, per match, never reused
type MatchEvent =
| { seq: Seq; kind: "ball"; innings: 1 | 2; over: number; legalBall: number;
batter: string; bowler: string; runs: number;
extras?: { type: "wd" | "nb" | "b" | "lb"; runs: number };
wicket?: { how: "bowled" | "caught" | "lbw" | "run out" | "stumped"; out: string };
at: string }
| { seq: Seq; kind: "correction"; amends: Seq; patch: Partial<BallFields>;
reason: "review" | "scorer"; note: string }
| { seq: Seq; kind: "state"; state: "innings-break" | "rain" | "review" | "resumed" | "result"; note: string };
type LiveSummary = {
matchId: string;
seq: Seq; // the last event folded in
score: { runs: number; wickets: number; overs: string }; // "32.3"
batters: [BatterLine, BatterLine];
bowler: BowlerLine;
lastBalls: string[]; // ["1", "4", "W", "0", "6", "1"]
recent: MatchEvent[]; // events after seq - 12, for catching up
overVersions: Record<string, number>; // "2-17": 3 after a correction
pollMs: number;
flags: { graphs: boolean; photos: boolean }; // switches for bad nights
};
Corrections are events too
At 20:14 the umpire gives Kohli out lbw, and the scorer enters it as event 231. Readers see IND 187/4. Ninety seconds later the third umpire overturns it on review. How do we record that?
Decision How do we represent a correction, such as an overturned wicket?
What we pick: Append a correction event. It keeps the one rule that makes the client simple (apply events in seq order) and lets the interface explain what changed. The server still publishes summaries that already include the correction, so a reader who arrives late just sees the right score.
Corrections come in two kinds. Review results change the story of the match, so the page shows them loudly: the wicket entry in the commentary gets struck through with "Not out on review" next to it. Scorer typos (a 1 that should have been a 2, entered three balls ago) are quieter: the numbers update and the affected ball shows a small "corrected" mark. Either way, nothing changes without a visible reason.
Think first
Should the client compute the scorecard itself from the full event log?
It could: a full ODI is about 600 events, a few hundred KB, and folding them takes milliseconds. But then every client must download the whole log first, and every client must run the exact same scoring rules (extras, retired hurt, penalty runs, the DLS method for rain). One bug becomes millions of wrong scorecards.
So the server folds events into summaries and scorecards, and the client mostly displays them. The client only applies small events to the views it is already showing, and when in doubt it refetches the server's view.
Check your understanding · Exercise 6
Why does each delivery need a seq instead of using the over and ball, like 32.3, as its key? Select all that apply.
The client: apply in order, never go backwards
The client receives two kinds of input: summaries from polls (each with a seq and the last dozen events) and, if a stream is on, single events. Either way, things go wrong in ways that are normal on mobile networks and CDNs, and the client must handle every one of them without showing a wrong score.
- Duplicates. Two polls return the same summary, or a stream event repeats after a reconnect.
- Out of order. Poll A goes to an edge in Mumbai that has
seq 231. Poll B, a few seconds later, goes to a different edge (the phone switched from Wi-Fi to 4G) that still hasseq 230. If the client just renders the latest response, the wicket disappears and the score goes back from 187/4 to 187/3. - Gaps. The phone was in a tunnel for two minutes and missed 3 events, or more than the dozen the summary carries.
- Corrections. Event 233 amends event 231.
Four rules handle all of them.
- Drop anything with a
seqyou already have, so applying twice is harmless (the operation is idempotent). - Apply only the next
seq; hold later ones in a small buffer. - If a gap stays open, fetch a fresh snapshot rather than guess.
- Ignore a summary older than what is on screen.
Rule 4 is what makes the score never go backwards because of a stale edge. The only way a number goes down is a correction event, and that comes with its own label.
function applySummary(s: LiveSummary) {
if (s.seq <= state.seq) return; // rule 4: stale edge or duplicate
for (const e of s.recent) receive(e); // catch up from the summary's tail
if (state.seq < s.seq) { // tail didn't reach far enough back
requestSnapshot(); // rule 3
return;
}
state.summary = s;
}
function receive(e: MatchEvent) {
if (e.seq <= state.seq || state.buffer.has(e.seq)) return; // rule 1
state.buffer.set(e.seq, e);
while (state.buffer.has(state.seq + 1)) { // rule 2
const next = state.buffer.get(state.seq + 1)!;
state.buffer.delete(next.seq);
applyEvent(next); // a ball, a state change, or a correction
state.seq = next.seq;
}
if (state.buffer.size > 0) startGapTimer(); // rule 3 after 3 s
}
Decision When the client notices a gap (it has 229 and receives 231), what should it do?
What we pick: Wait briefly, then fetch a snapshot. A gap usually closes by itself: the late event arrives, or the next summary brings it in its tail of 12 recent events. When it doesn't, the snapshot comes from the same CDN cache as everything else.
The state machine in the figure is what the store really is. It starts in loading with the seq the server rendered into the HTML, and the first poll moves it to live, where events apply in order. A jump in seq moves it to gap, where it buffers and waits, and a 3 second timeout moves it to resyncing, which fetches a snapshot.
A hidden tab moves the store to paused, with no polls at all. Coming back means one poll after a short random delay and then live again; the score strip says "catching up" until that poll lands. Repeated failures (in the figure, three failed snapshots in a row) move it to stale, where the page shows the score with "Updated 2 min ago" and keeps retrying with backoff.
Try it below. Events arrive out of order, one is lost on the way, and a correction overturns a wicket. Step through it and watch the buffer, the gap detection, the snapshot fetch, and the moment the score is allowed to go down.
IND 182/3 (32.0). The page was server-rendered at seq 228 showing IND 182/3 (32.0). Press Next arrival.
Check your understanding · Exercise 7
The page shows seq 231 (187/4). A poll returns a summary with seq 230 (187/3). What should the client do? Select all that apply.
The page: server-rendered, with small live islands
Most of the page never changes after it loads: the header, the team names, the venue, the navigation, the article links. Only a few parts are live: the score strip, the latest commentary, and whichever scorecard or graph is open. On a phone with a slow CPU, shipping a framework to hydrate the whole page just to update 300 bytes of score is the most expensive mistake available. The ssr and islands-and-partial-hydration lessons explain the mechanics.
Decision How should the match page be rendered?
What we pick: Server-rendered HTML with live islands. The first response must contain the score as text, because on a bad network it may be the only thing that arrives. The HTML embeds the seq it was rendered with, so the score island starts its first poll from there and catches up within seconds, even if the cached HTML is 10 seconds old.
Budget the JavaScript like money. A target for this page: under 50 KB of compressed JavaScript for the score strip, the sync store and the commentary island together, with graphs and the full scorecard loaded only when their tab opens. The performance-budgets lesson covers how to enforce such a budget in CI
For search, the HTML carries schema.org SportsEvent structured data (name, startDate, location, the two teams as homeTeam and awayTeam or competitor). It describes the match to search engines, but it doesn't earn a special result: Google's list of supported rich results has no sports score type, and its own live score cards don't come from publishers' markup.
Commentary: newest on top, without stealing the scroll
Commentary is a list that grows at the top. If a reader is at the top, a new ball slides in. If the reader has scrolled down to read the 28th over, inserting an item above them would push their text down mid-sentence. So the list holds new items in a pending queue and shows a "3 new balls" pill; tapping it inserts them and scrolls to the top
Browser scroll anchoring (controlled with the CSS property overflow-anchor) keeps the view steady when content is inserted above it. Chrome and Firefox have had it for years, but Safari only added it in version 27 in 2026, so older iPhones still jump, and the pill pattern stays the safer default. The infinite-scroll guide covers the scroll mechanics in depth.
How long is the list? A T20 has about 260 deliveries, an ODI about 620, and a five-day Test can pass 2,500. Older overs load one over at a time as you scroll, from the immutable over files, and each over block uses content-visibility: auto so the browser skips rendering off-screen overs. That is enough for T20 and ODI; for Tests, real virtualization (rendering only the rows in view, as the virtualization lesson explains) is worth its complexity.
Scorecard and graphs
The scorecard is a real table with th scope="col" headers, the batter's name as the row header, and a sticky first column so it scrolls sideways on a narrow screen. It is fetched when its tab opens and refreshed only while it is visible
The worm (cumulative runs by over, one line per team) and the manhattan (one bar per over, wickets as dots) have at most 50 points each in an ODI. Draw them as small inline SVG with a few lines of code, not a chart library that weighs more than the rest of the page. They change once per over, so they re-render once per over, not once per ball.
Check your understanding · Exercise 8
A reader is reading the 28th over while three new balls arrive. What should happen? Select all that apply.
Architecture and the API
Put the pieces together. On the server side, the scorer's tool writes events to the match log. A publisher folds each new event into the views (live summary, recent commentary, scorecard, the current over file) and writes them as static JSON to an origin store. Nothing on the read path runs a database query: the origin just serves files that the publisher keeps current, like a static site rebuilt every ball.
seq, which is how the client orders copies of different ages.On the client, one small sync store owns the match state and the delivery loop. The islands only read from it. That boundary matters: the score strip, the commentary and the scorecard tab must agree on the same seq, and only one poll loop should run per tab, however many islands are on screen.
| Part | Responsibility | Why the boundary is here |
|---|---|---|
| Score strip island | Score, overs, rates, batters, bowler, last 6 balls; sticky at the top | The one widget everyone looks at; must be tiny and hydrate first |
| Commentary island | Pending queue, new-balls pill, loading older overs | Owns scroll state, which no other part should touch |
| Tab islands: scorecard, graphs | Loaded on first open; refresh only while visible | Keeps their code and data off the critical path |
| Sync store | seq, summary, buffer, the state machine, corrections | One source of truth so no two widgets disagree |
| Delivery client | Poll loop with jitter, pollMs, backoff; optional stream | Swappable transport without touching the store |
| Visibility and network watcher | visibilitychange, online and offline, Save-Data | Pauses and resumes delivery in one place |
| Announcer | Rate-limited live region for wickets and boundaries | Accessibility logic in one place, fed by the store |
The API: static files with a contract
The API is a handful of GET endpoints that return JSON, all cacheable, none personal except /me. The contract is the seq: every response says which event it reflects, so the client can always order them.
GET /live/m/{matchId}.json -> LiveSummary edge 2 s, swr 4 s
GET /live/{lang}/m/{matchId}/recent.json -> { seq, items[] } edge 2 s, swr 4 s
GET /live/m/{matchId}/card/{innings}.json -> Scorecard edge 5 s, swr 10 s
GET /m/{matchId}/{lang}/over/{inn}-{over}.json?v={n}
-> { seq, items[] } max-age=86400, immutable
GET /m/{matchId}/info.json -> teams, squads, names per language
GET /me -> follows, push opt-in (private, no-store)
Optional stream: GET /stream/m/{matchId} (text/event-stream)
id: 233
event: correction
data: {"seq":233,"amends":231,"patch":{"wicket":null},"reason":"review"}
Errors stay boring on purpose. A match that doesn't exist is a cached 404. An origin failure is never seen by readers, because edges serve the last copy under stale-if-error. A 503 with Retry-After from the stream tier tells clients to fall back to polling, as the resilience chapter shows.
Check your understanding · Exercise 9
Why does the publisher write static JSON files instead of the API querying a database on each request? Select all that apply.
Cheap phones, flaky networks and spoilers
Our typical reader holds a mid-range Android phone in a crowded place, on a data pack. Three things matter to them: how many megabytes the match costs, whether the page keeps working when the signal drops, and how much battery it eats. Then there is a fourth, odd problem: the score page is faster than the video stream.
What a match costs in data
A T20 match lasts about 3 hours 20 minutes, around 12,000 seconds, so polling every 5 seconds is about 2,400 polls. Seven in eight are 304s at about 150 bytes, and one in eight are 200s at about 1 KB: roughly 315 KB plus 300 KB, about 0.6 MB for the whole match, plus a few hundred KB for the first load
Compare an old-style page that reloads itself every 30 seconds at 300 KB a time: 400 reloads, about 120 MB. Tiny, conditional polls are not just cheaper for us, they are about 200 times cheaper for the reader.
- Data saver. If the browser sends
Save-Data: on(ornavigator.connection.saveDatais true, a Chromium-only API), doublepollMs, skip team logos and player photos, and don't preload the graphs tab. - Timeouts. Mobile requests can hang for a minute without failing. Every poll uses
AbortSignal.timeout(8000), and a timeout counts as a failure for backoff. - Offline. On the
offlineevent, stop polling and show "No connection, last update 20:14"; ononline, poll once straight away. Keep the last summary in memory so the page never goes blank.
Background tabs and battery
Many readers switch to WhatsApp between balls. A hidden tab polling every 5 seconds wastes data, and each request wakes the phone's cellular radio, which then stays in a high-power state for a few seconds. Browsers do throttle timers in hidden tabs: Chrome runs them at most once a second at first, and once a minute for chained timers after a silent tab has been hidden for 5 minutes. A 5 second poll survives the first stage, so we can't rely on the browser
The visibility watcher listens for visibilitychange: when document.visibilityState becomes hidden, the delivery loop stops. When it becomes visible, the store polls once after a random delay of 0 to 1 second, so a push alert that brings a million people back at once doesn't create a synchronized wave. The summary's 12 event tail covers short absences; longer ones trigger a snapshot.
Spoilers: the page is ahead of the stream
Live video on phones is delayed by encoding, packaging and buffering: often 10 to 40 seconds behind the stadium with standard streaming formats, less with low-latency ones, and it varies by app and network. Someone watching the stream with the score page open sees "WICKET" before the bowler has started his run up on their screen. Some fans love being first; others find it ruins the moment.
Decision How should the page deal with readers who watch a delayed stream?
What we pick: A client-side delay setting, off by default. It keeps the broadcast identical for everyone (the CDN still serves one copy) while letting each reader shift their own view. The setting lives in localStorage, and the page shows a small "Delayed 25 s" badge so nobody forgets it is on.
Check your understanding · Exercise 10
A reader switches to WhatsApp for 4 minutes and comes back. Which behaviours are right? Select all that apply.
Degrading gracefully, and alerts that don't make it worse
On the biggest night of the year something will go wrong: an origin region slows down, the stream tier (if we run one) runs out of sockets, a deploy goes bad. The rule for this page is that readers see an older score, then a simpler page, but never an error. Plan the steps down in advance, so that on the night each step is a switch, not a code change.
pollMs and flags, so they reach every phone within one poll.| Step | Trigger | What readers notice |
|---|---|---|
| 0. Normal | All healthy | Updates in 3 to 8 seconds |
| 1. Slow down | Edge or origin load high | pollMs raised to 15 s; updates in up to 20 seconds |
| 2. Shed heavy widgets | Final overs of a huge match, or CPU alarms | Graphs, photos and scorecard auto-refresh switched off by flags; score and commentary stay |
| 3. Streams to polls | Stream tier full or failing | Stream clients get 503 with Retry-After and switch to polling; a few seconds slower |
| 4. Serve stale | Origin down | Edges serve the last copy under stale-if-error; the strip says "Updated 2 min ago" |
| 5. Static snapshot | Page rendering broken | A plain HTML page with the score, regenerated every 30 s by a separate job |
Where do the switches live? The summary already reaches every client every few seconds, so pollMs and the flags ride inside it (flags: { graphs: false, photos: false }), and turning a widget off takes effect within one poll
Two details make that safe: the publisher changes the ETag when only the control fields change, so a switch isn't hidden behind a 304, and the client reads them from every response, even one whose seq rule 4 ignores. The feature-flags lesson covers flag design in general; the trick here is reusing the hottest, best-cached response as the control channel.
The client must also avoid making things worse. Retries back off exponentially with jitter up to a minute. A stream client that fails three times stays on polling for the rest of the session instead of retrying the stream forever. And the stale label is honest: when the last good update is more than 30 seconds old, the strip says so, because a score that looks live but isn't is worse than one that admits it is late.
Wicket alerts
Fans who follow a team can get web push notifications for wickets, fifties and hundreds, and the result. The push-notifications lesson and the notifications-center guide cover subscriptions, service workers and permission prompts. Three details are specific to cricket.
- Rate limits and collapsing. At most one alert per match per minute per reader, and each notification uses the same
tag(for examplematch-4521), so a new one replaces the old one instead of stacking ten wicket alerts on the lock screen. - Quiet hours and choice. A match in Australia can end at 4 a.m. in India. Readers choose which events they want and set quiet hours; the result alert waits until morning.
- Alerts cause spikes. Five million alerts at once bring a wave of first loads within seconds. Sending them in batches over 20 to 30 seconds flattens the wave, and the alert links to the cached match page, so the wave lands on the edge, not on us.
Check your understanding · Exercise 11
The origin goes down for 3 minutes during the final over. What do readers see with this design? Select all that apply.
Accessibility and language
A blind fan follows the match through a screen reader. A fan in Chennai reads commentary in Tamil. Both are core readers, not edge cases, and both affect how the live parts are built. The screen-readers and locale-formatting lessons cover the basics; here are the parts that a live score changes.
Announcing without flooding
The tempting fix is aria-live="polite" on the score strip. Then every re-render is read aloud: run rate, required rate, the batter's strike rate, every 5 seconds. A screen reader user hears a stream of numbers and can't read anything else. Instead, keep the strip silent and use one separate announcer: a visually hidden role="status" element that the store writes to only for events that matter.
- Announce wickets and the result always, boundaries and milestones by choice, and every ball only if the reader asks for it.
- Write full sentences, not symbols: "Wicket. Kohli out lbw for 74. India 187 for 4 after 32.3 overs." Not "W 187/4".
- Rate limit: at most one announcement every 10 seconds. If several events arrive during a catch-up, announce one summary ("3 balls missed: 1 wicket, 6 runs. India 193 for 5.") instead of three.
- Announce corrections too: "Correction: Kohli not out on review. India 187 for 3."
- Keep the score as real text in the HTML, with the last six balls as a list whose items say "four", "wicket", "dot ball", so nothing is only colour or an icon.
WCAG success criterion 2.2.2 (Pause, Stop, Hide) applies here: auto-updating information that starts on its own and sits alongside other content needs a way to pause, stop or hide it, or to control how often it updates. So the score strip has a "Pause updates" button (aria-pressed). Paused, the store keeps syncing in the background but the strip, the commentary and the announcer freeze; resuming jumps to the current state with one summary announcement, which is what the WCAG guidance suggests for real-time information. With prefers-reduced-motion, new balls appear without sliding in.
Many languages, one score
The score is numbers and shared by all languages: it lives at one URL. Commentary is text written by commentators per language, so it lives at a path per language, a handful of cached copies. Player and team names come from info.json with a name per language, so the live summary only carries player ids and stays one copy for everyone.
Watch two formatting traps. First, overs are not decimals: 32.3 means 32 overs and 3 balls, and the total goes from 32.5 to 33.0, so store a count of legal balls (or overs and balls as two integers) and format it yourself; adding overs as floats gives nonsense, because 32.5 plus one ball is not 32.6
Second, Indian readers expect Indian grouping for large numbers, which Intl.NumberFormat("en-IN") gives (1,00,000), and times in their own time zone with Intl.DateTimeFormat. For Hindi, Tamil and other scripts, use the system fonts that Android ships rather than downloading web fonts that can weigh hundreds of KB.
Check your understanding · Exercise 12
Which choices give a screen reader user a good live experience? Select all that apply.
Running it in the interview
This question rewards one insight said early: the score is a broadcast of identical bytes, so the CDN does the work. Candidates who start with "WebSocket server, Redis pub/sub" spend the hour scaling sockets. Candidates who say "20 million readers, same answer, cache it for 2 seconds" spend the hour on the interesting parts: ordering, corrections, the page, and the phone.
| Minutes | What to do | What to draw or say |
|---|---|---|
| 0 to 6 | Scope | Readers, peak minute, freshness target, anonymous or not, devices, languages |
| 6 to 12 | The load shape and the estimate | Spikes at wickets; 20 million / 5 s = 4 million requests per second; a ball every 40 s |
| 12 to 25 | Delivery | Broadcast vs conversation; polling vs SSE vs WebSocket with numbers; TTL, collapsing, shield, jitter, pollMs |
| 25 to 35 | Data model and client sync | Event log with seq; corrections as events; the four apply rules; the state machine |
| 35 to 45 | The page | SSR with the score in HTML, islands, commentary pill, scorecard and graphs on demand |
| 45 to 55 | Phones, failure, access | Data per match, hidden tabs, spoiler delay, degradation steps, announcer and pause |
| 55 to 60 | Wrap up | Recap the trade-offs and what you would measure on match day |
Draw the data flow diagram first, from scorer to phone, with the edge in the middle, because every later answer points at it. Then the client's state machine. Say the numbers out loud; interviewers remember a candidate who computed that seven polls in eight are 304s.
Think first
Follow-up: "The business wants the score within 1 second for everyone." What do you say?
Ask why first: TV runs a few seconds behind and streams 10 to 40 seconds, so 1 second only matters for betting or a premium product. If it is truly needed, move the engaged, foreground readers to SSE through a CDN or a managed fan-out tier that supports streaming, keep polling as the fallback, and keep the same seq and apply logic so both paths agree.
Then give the cost: millions of open connections all match long, a reconnect plan for server restarts (jittered reconnects, Last-Event-ID resume), and capacity for 20 million messages per ball.
Think first
Follow-up: "How would you know on match day that it is working?"
Measure freshness from the reader's side: each client reports a sample of (time shown minus event time) through real user monitoring, so you see the 95th percentile delay per region and network type. Watch the edge hit ratio (it should stay above 99% for the live summary), origin requests per second (flat, regardless of the crowd), the 304 ratio, and the rate of gap snapshots.
Alert on the client-reported share of readers with a stale label showing. That single number tells you whether fans are seeing an old score, whatever the cause.
Summary
- The score is a broadcast: one writer, millions of identical readers. Keep the hot response free of cookies and personal fields so a CDN can share one copy with everyone.
- With a ball every 40 seconds and video streams 10 to 40 seconds behind, short polling a small JSON with a 1 to 2 second edge cache beats millions of open sockets on spike handling, resilience and reach. Pay-as-you-go CDNs bill it per request, so at list prices it can cost far more than a stream tier: the poll interval and the CDN contract are the cost dials.
- Request collapsing, stale-while-revalidate and an origin shield make origin load flat: a few requests per second, whatever the size of the crowd or the wicket spike.
- ETags make seven polls in eight a tiny 304; jitter stops synchronized waves; a server-chosen
pollMsfollows the match state and doubles as an emergency dial. - Model the match as an append-only event log with a sequence number. Corrections are new events that amend old ones, and the page shows them explicitly.
- On the client, drop duplicates, apply only the next
seq, buffer and then snapshot on gaps, and ignore older summaries, so the score never goes backwards by accident. - Server-render the score into the HTML and hydrate only small islands; budget JavaScript for a 3G Android phone and stop polling in hidden tabs.
- Plan the steps down (slower polls, flags off, streams to polls, stale, static) and keep the stale label honest.
- Announce wickets through one rate-limited status region, offer a pause control, and treat overs as a count of balls, not a decimal.
Check your answer
Tick what your own design covered. Pick the level you're interviewing for: each level includes the ones before it.
References
- Fastly: Request collapsing How a CDN merges simultaneous misses into one origin request.
- Amazon CloudFront: Using Origin Shield A regional cache layer between edges and the origin.
- RFC 5861: HTTP Cache-Control extensions for stale content The definitions of stale-while-revalidate and stale-if-error.
- RFC 9213: Targeted HTTP Cache Control The CDN-Cache-Control header, read by CDNs and ignored by browsers.
- Amazon CloudFront: Request and response behavior for custom origins Includes how CloudFront collapses simultaneous requests for the same object.
- MDN: Cache-Control max-age, s-maxage, immutable and the other directives used here.
- MDN: Using server-sent events EventSource, event ids and reconnection.
- MDN: Document visibilitychange event Pausing work in hidden tabs.
- Chrome for Developers: Heavy throttling of chained JS timers What happens to timers in tabs hidden for 5 minutes or more.
- Understanding WCAG 2.2 SC 2.2.2: Pause, Stop, Hide Why auto-updating content needs a pause control.
- Schema.org: SportsEvent Structured data for a match: teams, start date, location.
- Google Search Central: Structured data markup that Google Search supports The list of rich result types; there is no sports score type.
- MDN: PushManager.subscribe() Push subscriptions and the userVisibleOnly option.
Concepts behind it
- Short pollingThe browser asks the server "anything new?" on a timer: the simplest way to keep a page fresh, and the right answer more often than people think.
- CDN cachingKeeping shared copies of your responses on servers near every user, so most requests never travel to your origin: the biggest lever for global speed and for surviving traffic spikes.
- HTTP cachingThe browser's built-in cache, steered by headers on each response, which lets a repeat visit skip the network entirely or ask only "has this changed?": the cheapest speed-up on the web.
- Server-sent eventsOne HTTP response that never finishes: the server keeps writing small text events to it, and the browser's
EventSourceparses them, reconnects on its own, and resumes where it left off. The simplest push channel when data flows from server to client. - Islands and partial hydrationShip a page as static HTML and send JavaScript only for the few parts that need it, each waking up on its own trigger, so content pages stay fast on slow phones instead of paying to hydrate the whole app.
- List virtualizationRendering only the rows the user can see in a long list, so a list of 100,000 items costs about as much as a list of 30.
- Screen readers and live regionsHow screen readers turn a page into speech or braille, how every control gets its accessible name and description, and how to announce changes like search results, saves, and errors with live regions.
- Performance budgets and monitoringAgreed limits on what a page may cost (kilobytes of JavaScript, LCP, INP) that CI checks on every pull request and real-user monitoring watches in production, so speed stops getting worse one small change at a time.