cf.completefrontend
My progress
SYSTEM DESIGN · #77

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.

QuestionAnswer we assumeWhat 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 minuteEvery 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 stadiumWe 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 toolOne writer, millions of identical readers: a broadcast
Are readers signed in?Mostly not; the page is the same for everyoneThe 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 trainA tiny JavaScript budget and payloads of a few hundred bytes
Which languages?English, Hindi, Tamil, Bengali, Telugu and more for commentaryCommentary is per language; the score itself is numbers
Is there video?Not on this page; streaming rights belong to the broadcasterNo video player; we must avoid spoiling people who watch a delayed stream
Alerts?Yes, for fans who follow a team: wickets, fifties, the resultWeb 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

  1. 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?
  2. 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.
  3. 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.
  4. 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.
  5. 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.
A line chart of readers on the page through one match. Readers rise from the toss to the start, spike sharply at a first-innings wicket, dip during the innings break, then climb through the chase with taller spikes at two more wickets, and reach the highest point in the final over. A dashed line marks the average, far below the peaks.readers on the pageaveragewicketwicketwicketfinal overtossstartinnings breakthe chase
Capacity is planned for the peaks, not the average. Each spike is mostly new readers arriving at once, so first page loads matter as much as polls.

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.

QuantityEstimateHow we got it
Readers on the page at the peak20 millionBelow the 59 million video peak; many watchers don't also open a score page
Poll interval while liveabout 5 secondsFresh enough: a ball every 40 seconds, and video streams run 10 to 40 seconds behind
Requests per second at the edgeabout 4 million20 million / 5 s
Size of one score responseabout 1 KB on the wire when the score changed; about 150 bytes for a 304 Not ModifiedAbout 600 bytes of compressed JSON (the score and a short tail of recent events) plus response headers
Times the score actually changesabout once every 40 secondsOne ball per 40 s, plus a few corrections
Polls that see no changeabout 87%With a 5 s poll and a 40 s gap between balls, only 1 poll in 8 sees a new ball
Edge bandwidthabout 1 GB/s, roughly 8 Gbit/s4 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?

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.

Two halves. Left, a conversation such as a chat: three readers, you, Asha and Ravi, each write to and read from a chat server that keeps a stream per user; many writers, different bytes per reader. Right, a broadcast such as a score: one scorer, the single writer, feeds an edge copy holding the same few hundred bytes for everyone; one writer, the same bytes for 20 million readers.conversation: chatyouAshaRaviChat servera stream per usermany writersdifferent bytes per readerbroadcast: scoreScorerone writerEdge copyone copy for allone writersame bytes for 20 million
If every reader needs different bytes, you need a connection per reader. If every reader needs the same bytes, you need a copy machine, and a CDN is the biggest copy machine there is.

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-Cookie header 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: private or no-store copied from the API's default headers.
  • A Vary header on something with many values, such as Vary: Cookie or Vary: 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)?

One API response per user

The score endpoint reads the session cookie and returns the score plus the reader's personal fields.

The CDN passes every request to the origin.

Pros

  • One request per poll
  • Simple to build

Cons

  • Nothing is cacheable: 4 million requests per second reach our servers
  • One slow database call during a wicket spike takes the whole page down

Shared score, separate small personal call

The score URL has no cookies and no personal fields: /live/m/4521.json.

Personal state (followed teams, push opt-in) comes from a separate /me call, once per page load, and is cached in the client.

Pros

  • The hot path is 100% cacheable
  • The personal call is rare and can fail without hiding the score

Cons

  • Two requests on first load
  • Care needed so no middleware adds cookies to the shared path

Personalize at the edge

An edge function fetches the shared score from cache and merges personal bits from a cookie before answering.

Pros

  • One request for the client
  • The shared part is still cached

Cons

  • Runs code on every poll: millions of function calls per second cost money
  • More moving parts in the place that must never fail

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.

ResourceExample URLSize (compressed)ChangesEdge cache lifetime
Live summary: score, batters, bowler, last 6 balls, sequence number, a short tail of recent events/live/m/4521.jsonabout 600 BEvery ball1 to 2 s
Recent commentary: the last 2 overs/live/en/m/4521/recent.jsonabout 2 KBEvery ball1 to 2 s
Scorecard of the current innings/live/m/4521/card/2.jsonabout 4 KBEvery ball, but read less often5 s
A completed over's commentary/m/4521/en/over/2-17.json?v=3about 1.5 KBAlmost never; only on a correction, which bumps v1 day, immutable
Match facts: teams, venue, squads/m/4521/info.jsonabout 3 KBOnce or twice per match60 s
Page shell: HTML, CSS, JavaScript/_next/static/...about 60 KBPer deploy1 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?

Short polling a cached JSON from the CDN

Every 5 seconds (with jitter), the client asks for /live/m/4521.json.

The edge answers from a copy that is at most 1 to 2 seconds old; only a few requests per second reach the origin.

If the client's ETag still matches, the answer is a tiny 304 Not Modified.

Pros

  • Stateless: no connection to keep, no reconnect storm
  • Uses CDN capacity that already exists in hundreds of cities
  • Works through every proxy, firewall and captive portal
  • Failure is graceful: a stale copy is still a score

Cons

  • Freshness is poll interval plus cache lifetime: about 3 to 8 seconds
  • Many requests return nothing new (mostly cheap 304s)
  • Pay-as-you-go CDN plans bill per request, so millions per second add up and the poll interval is also the cost dial

Server-sent events (SSE)

The client opens one EventSource to /stream/m/4521 and the server pushes each ball as an event with an id.

Over HTTP/2 or HTTP/3, the stream is one of many streams on a shared connection (servers usually allow at least 100 per connection). Over HTTP/1.1 it holds one of the browser's 6 connections per host for as long as the page is open.

On reconnect, the browser sends Last-Event-ID so the server can send what was missed.

Pros

  • About 1 second freshness
  • Only real changes travel; no empty polls
  • Built-in reconnect in the browser

Cons

  • One open stream per reader: millions of long-lived requests to hold
  • Needs a fan-out tier, or a CDN product that supports streaming fan-out
  • Some proxies and mobile networks buffer or cut long responses

WebSocket

The client opens wss://live.example.com and subscribes to match 4521; the server pushes each ball.

Pros

  • Lowest latency
  • Two-way, if we ever need it

Cons

  • Same connection cost as SSE, plus our own reconnect, heartbeat and resume logic
  • Two-way is wasted: readers never send anything
  • Plain CDNs cache nothing on a WebSocket

Push notifications as the delivery

The server sends a web push for every ball; the service worker updates the page.

Pros

  • Reaches readers even when the page is closed

Cons

  • Web push is meant for occasional alerts, not 20 million messages every 40 seconds
  • Chrome requires every push to show a visible notification (userVisibleOnly: true), so a silent per-ball update isn't allowed
  • Delivery is not guaranteed or ordered
  • Needs permission; most readers never grant it

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.

Fans
Transport
Poll everyEdge cache
CDN request fee
20.0M fans with poll the CDN, polling every 5 s, edge cache 2 s, collapsing on, jitter on. A line chart shows edge requests per second over 40 seconds, peaking at 4.0M. Edge requests 4.0M per second, origin 5 per second at peak, open sockets 0, stream servers 0. Freshness 4.0 s on average and 8.5 s at worst; 615 KB per reader per match; about 14,585 dollars an hour at assumed prices: 14,400 for CDN requests and 185 for data.edge requests per second, 40 speak 4.0M/s0 s20 s40 sedge: 4.0M req/sorigin: 5, peak 5open sockets: nonestream servers: 0fresh: 4.0 s, worst 8.5 sdata: 615 KB per matchcost: $14,585 an hourrequests $14,400, data $185
The origin sees 5 requests per second however many fans poll. Readers are 4.0 s behind on average, and at list prices the request fees are almost the whole bill. Assumptions: 300 edges, 10 shield regions, a 200 ms origin fetch, 200k sockets per server at $1 an hour with 2x headroom, $0.05 per GB, and $1 per million CDN requests, a list price that large buyers negotiate down.

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?

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?

No caching, collapsing only

Cache-Control: no-cache at the edge; the CDN merges requests that arrive while one is in flight.

Pros

  • Freshest possible answer

Cons

  • Every edge goes to the origin for nearly every poll wave
  • Collapsing alone still lets through one request per edge per round trip

1 to 2 seconds, plus stale-while-revalidate

max-age=2, stale-while-revalidate=4 in a header only the CDN reads (more on that below).

When the copy expires, the edge keeps answering with it while one background request refreshes it.

Pros

  • Each edge asks the origin at most about once every 2 seconds
  • No reader waits for the origin
  • Freshness cost is small next to a 40 second gap between balls

Cons

  • A reader can be up to about 2 seconds staler than the origin
  • The CDN must support these directives (CloudFront, Fastly and Cloudflare do) and allow a 2 second lifetime: check settings such as CloudFront's minimum TTL, which overrides a shorter header value

Long lifetime, purge on every ball

Cache for 60 seconds; the publisher calls the CDN's purge API whenever the score changes.

Pros

  • Fresh within the purge time and very few origin requests between balls

Cons

  • A purge empties every edge at the same moment: a herd at exactly the worst time, a wicket
  • If one purge fails, readers are stuck on an old score for a minute

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.

Sequence diagram of polling through the CDN. The phone polls the edge with its ETag for seq 217 and gets a 304 with no body. The edge's copy expires, and it sends one refresh to the shield. Meanwhile more polls arrive and the edge answers them at once with the stale copy. The shield sends one request per region to the origin, which replies 200 with seq 218. The shield hands the new copy to the edge, collapsing all waiting requests, and the edge answers the phone with 200 and seq 218.PhoneEdgeShieldOriginpoll, ETag s217304, no bodyexpired: refreshmore pollsstale copy, at once1 per region200, seq 218new copy200, seq 218
No phone ever waits on the origin: stale-while-revalidate answers at once while a single collapsed refresh runs. The origin sees one request per region, however many phones are polling.
http
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
A poll that finds nothing new. The ETag is built from the match and its sequence number (plus a control revision, see the resilience chapter), so it changes whenever readers need new bytes. stale-if-error lets edges keep serving the last score for 10 minutes if the origin fails.

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.

ts
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);
  }
}
The whole delivery client: poll with an 8 second timeout, apply, let the server set the pace, back off on errors, add jitter every time.

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 statepollMsWhy
Before the toss60,000Only the toss and team news can change
Ball in play, normal overs5,000A ball every 40 seconds; 5 s keeps us ahead of video streams
Final 2 overs of a close chase, or a review in progress3,000Every ball matters and readers are watching the screen
Innings break (10 to 20 minutes in a T20)30,000Nothing changes until the restart
Rain delay or bad light60,000Updates are rare text notes
Match finished300,000, then stopOnly late corrections and awards
Overload switch on15,000 or moreThe 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.

ts
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
};
Events are the truth; the summary is a view of them, with the last dozen events inside so a client can catch up without another request.
Data model. A match, with teams and venue, has two innings. Each innings is an append-only event log keyed by seq: 229 a ball, 32.1, 1 run; 230 a ball, 32.2, a four; 231 a ball, 32.3, lbw out; 232 a state event, review; 233 a correction that amends 231. A note says the key is seq, not over and ball. Arrows labelled fold lead from the log to four derived views: the live summary with seq, score and the last 12 events; the scorecard per innings; the commentary per over, versioned; and the graphs, worm and manhattan. A note says each view carries its seq.Matchteams, venuehas 2Innings1 or 2key is seq,not over.ballEvent log, append-only229ball 32.1, 1 run230ball 32.2, FOUR231ball 32.3, lbw OUT232state: review233correction: amends 231derived viewsLiveSummaryseq, score, last 12Scorecardper inningsCommentaryper over, versionedGraphsworm, manhattanfoldeach carries its seq
Only the log is written; everything a reader sees is folded from it on the server, once per event. The correction at 233 is a new row, not an edit of 231, so every client can apply it in order.

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?

Edit the old event in place

Update event 231 to remove the wicket and republish.

Pros

  • Simple storage: the log always shows the final truth

Cons

  • Clients that already applied 231 have no signal that it changed
  • Cached copies of over 32 now disagree with the live summary
  • No record of what happened, which fans and commentators care about

Append a correction event that amends the old one

Write event 233: kind: "correction", amends: 231, patch: { wicket: null }, reason: "review".

The server refolds the views and bumps the version of over 32.

Pros

  • Clients apply it in order like any other event
  • The page can say "Overturned on review" instead of silently changing a number
  • The log stays an honest history and can be replayed

Cons

  • Folding is a little more complex: a later event can change an earlier one

Only republish a full snapshot

After any correction, tell clients to throw away their state and reload the whole match.

Pros

  • Always correct in the end

Cons

  • Millions of full reloads at the same moment, right after a dramatic review
  • The reader sees the score jump with no explanation

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?

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 has seq 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.

  1. Drop anything with a seq you already have, so applying twice is harmless (the operation is idempotent).
  2. Apply only the next seq; hold later ones in a small buffer.
  3. If a gap stays open, fetch a fresh snapshot rather than guess.
  4. 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.

ts
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
}
The whole sync logic is about 20 lines. It works the same whether events come from polls or from a stream.

Decision When the client notices a gap (it has 229 and receives 231), what should it do?

Render 231 anyway

Apply events as they come, whatever their order.

Pros

  • Fastest display

Cons

  • The commentary misses a ball and the totals drift from the truth
  • Later corrections may amend an event the client never saw

Wait briefly, then fetch a snapshot

Hold 231 in the buffer. If 230 arrives within about 3 seconds, as a late stream event or in a poll's tail, apply both.

Otherwise fetch the summary and the current over; reset to its seq.

Pros

  • Most gaps close by themselves
  • The fallback is a cached, cheap request

Cons

  • Up to a few seconds of delay in the rare gap case

Reload the whole page

location.reload() on any gap.

Pros

  • Simple

Cons

  • Downloads the whole page again on a 3G phone
  • Loses scroll position in the commentary
  • Synchronized reloads after a network blip make their own herd

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.

State machine of the client's sync store. Loading goes to Live on the first poll after the server-rendered seq. Live goes to Paused when the tab is hidden, and Paused goes back to Live when visible, after one poll. Live goes to Gap when the seq jumps, and back to Live when the gap fills. Gap goes to Resyncing after a 3 second timeout. Resyncing goes to Live when the snapshot arrives, or to Stale after failing 3 times. Stale goes back to Live when a poll succeeds. A note says paused means no polls and stale means the score shows its age while retrying with backoff.Pausedtab hiddenLiveapply in orderGapbufferingLoadingSSR seqStalebackoffResyncingsnapshothiddenvisible: pollseq jumpsgap fills3 s timeoutsnapshot okfails 3 timespoll okfirst pollpaused: no polls; visible again: one poll after 0 to 1 sstale: last score with its age, retries with backoff
Every screen state has a name, so the score strip can show the right label: live, catching up, or updated 2 minutes ago. Impossible mixes, like paused while resyncing, can't happen.

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.

Client
Step 0 of 8

IND 182/3 (32.0). The page was server-rendered at seq 228 showing IND 182/3 (32.0). Press Next arrival.

Ball log lab, seq-ordered client, step 0 of 8. Events 228 to 233: 228 applied, 229 not seen, 230 not seen, 231 not seen, 232 not seen, 233 not seen. The score shows IND 182/3 (32.0). state: live. Times the score went backwards with no reason: 0. Screen reader last heard: nothing yet.event log228229230231232233green: applied, hatched: buffered, red: missingIND 182/3 (32.0)state: liveapplied in seq orderbackwards: 0screen reader: nothing yetseq on screen: 228
The page was server-rendered at seq 228 showing IND 182/3 (32.0). Press Next arrival.
Sequence diagram of a gap and a failed snapshot. After a tunnel, the delivery client polls the edge and gets a summary with seq 236. It hands it to the sync store, whose seq is 220, but the summary's tail starts at 225. The store reports a gap from 221 to 224 and asks for a snapshot. The delivery client requests the snapshot; the request times out after 8 seconds. It retries after 2 seconds plus jitter, and the snapshot with seq 236 reaches the store, which renders once and makes one announcement.DeliveryEdgeSync storeScreenpoll after tunnelsummary, seq 236tail from 225gap 221-224GET snapshottimeout, 8 sretry, 2 s + jittersnapshot, seq 236render once
The screen keeps the last good score with its age label the whole time; nothing is guessed. A timeout is just another failure for backoff, and the retry is jittered so a million returning phones don't retry in step.

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?

Client-rendered app

Send an empty shell and a JavaScript bundle; the bundle fetches the score and renders everything.

Pros

  • One code path, easy navigation between matches

Cons

  • Nothing to read until the bundle downloads and runs: several seconds on 3G
  • Search engines and link previews see an empty page

Server-rendered, fully hydrated

Render the HTML with the current score on the server, then hydrate the whole page with React.

Pros

  • Fast first paint with the real score
  • Good for search

Cons

  • The phone still downloads and runs JavaScript for static parts
  • Slow interaction during hydration on low-end phones

Server-rendered HTML with live islands

The HTML is rendered with the score and cached at the edge for about 10 seconds.

Only the score strip, the commentary list and open tabs hydrate; everything else stays plain HTML.

Pros

  • Readable on the first response, even before any script runs
  • JavaScript is a small fraction of a full app
  • The cached HTML absorbs the first-load part of a wicket spike

Cons

  • Islands need a small shared store to stay consistent
  • More care in deciding what is static

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.

Wireframe of the live match page. A score strip reads IND 187/4 (32.3) with the text need 98 off 105 and a Pause updates button. Below it, three cells show the batters Kohli 74 not out off 61 and Rahul 21 off 30, and the bowler Afridi 6.3 overs, 0 maidens, 41 runs, 2 wickets. The last six balls, oldest first, read 0, 6, 1, 1, 4, W, with the label Updated 3 s ago. Tabs read Live, Scorecard and Graphs, and a pill says 3 new balls. The commentary panel lists 32.3 Afridi to Kohli, lbw, under review; 32.2 Afridi to Kohli, FOUR through cover; 32.1 Afridi to Rahul, 1 run.cricket.example/ind-vs-pakIND 187/4 (32.3)need 98 off 105Pause updatesKohli 74* (61)Rahul 21 (30)Afridi 6.3-0-41-2last 6 balls06114WUpdated 3 s agoLiveScorecardGraphs3 new balls32.3 Afridi to Kohli, lbw, under review32.2 Afridi to Kohli, FOUR through cover32.1 Afridi to Rahul, 1 run
The score strip is the one widget everyone reads, so it is server-rendered as text and hydrates first. The pill holds new balls until the reader asks for them, and the freshness label is always visible.

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.

Data flow from scorer to screen. Top row: the scorer tool, the single writer, sends a ball event to the append-only event log; the publisher folds it into views and writes static JSON files to the origin. The origin shield pulls from the origin about once every 2 seconds. Second row, right to left: the shield sends one copy per edge to the edge caches, which answer phones with 200 or 304; the browser HTTP cache handles ETags and hands the summary to the sync store. The sync store passes seq and state down to the islands: score, commentary and tabs. A note says 4 million polls per second at the edge become a few per second at the origin.Scorer toolone writerEvent logappend-onlyPublisherfolds viewsOrigin filesstatic JSONballfoldwritepull, 1 per 2 sShieldcollapsesEdge caches-maxage 2HTTP cacheETag, 304Sync storeseq, buffer1 per edge200 or 304summaryseq, stateIslandsscore, commentary, tabs4 million polls/s at the edgea few per second at origin
Work is done once per ball on the left and copied millions of times on the right. The only thing that crosses every arrow is a 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.

Client architecture. A zone of hydrated islands holds the score strip, which hydrates first; the commentary with its pill and older overs; the tabs for scorecard and graphs, loaded on first open; and the announcer, a role status region. Below, a client zone holds the sync store with seq, summary and buffer; the delivery client that polls with jitter or uses an optional stream; and the watcher for visibility, online state and Save-Data. The delivery client sends summaries to the sync store, the watcher pauses and resumes delivery, and the store passes seq and state up to the islands. The delivery client polls the CDN edge about every 5 seconds. Notes say there is one poll loop per tab and that the header, navigation and match info stay plain HTML with no JavaScript.Islands (hydrated)Score striphydrates firstCommentarypill, oversTabson first openAnnouncerrole=statusseq, stateClient servicesSync storeseq, bufferDeliverypoll + jitterWatchervisibility, netsummariespauseresumeGET about every 5 sCDN edgecached JSONone poll loopper tabheader, nav, info:plain HTML, no JS
Islands only read the store, and only the delivery client talks to the network. That is why the transport can change from polling to a stream without touching any widget.
PartResponsibilityWhy the boundary is here
Score strip islandScore, overs, rates, batters, bowler, last 6 balls; sticky at the topThe one widget everyone looks at; must be tiny and hydrate first
Commentary islandPending queue, new-balls pill, loading older oversOwns scroll state, which no other part should touch
Tab islands: scorecard, graphsLoaded on first open; refresh only while visibleKeeps their code and data off the critical path
Sync storeseq, summary, buffer, the state machine, correctionsOne source of truth so no two widgets disagree
Delivery clientPoll loop with jitter, pollMs, backoff; optional streamSwappable transport without touching the store
Visibility and network watchervisibilitychange, online and offline, Save-DataPauses and resumes delivery in one place
AnnouncerRate-limited live region for wickets and boundariesAccessibility 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.

text
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"}
Every live response carries seq. The optional SSE stream uses seq as the event id, so Last-Event-ID resumes exactly where the client stopped.

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 (or navigator.connection.saveData is true, a Chromium-only API), double pollMs, 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 offline event, stop polling and show "No connection, last update 20:14"; on online, 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?

Always show events immediately

Apply each event as soon as it arrives.

Pros

  • Simplest; best for readers without video

Cons

  • Spoils every wicket for stream watchers

Server delays for everyone

The publisher holds events for 30 seconds before releasing them.

Pros

  • No client work

Cons

  • Punishes the majority who have no video
  • No single delay matches every stream and network

A client-side "match my stream" delay

The reader picks a delay (0 to 60 seconds) or taps "now" when a ball lands on their screen to calibrate it.

The store keeps receiving events but releases them to the screen at event time + delay.

Pros

  • Each reader gets their own delay with no change to the shared, cached data
  • Sync, gap handling and corrections work exactly the same

Cons

  • Push alerts arrive undelayed, so they should be muted while the page is open with a delay set

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.

A staircase of six planned steps down. 0, normal: updates in 3 to 8 seconds. 1, slow down: poll every 15 seconds. 2, shed widgets: graphs and photos off. 3, streams to polls: stream clients fall back to polling. 4, serve stale: stale-if-error with an age label. 5, static snapshot: plain HTML regenerated every 30 seconds. A note says the flags ride in the summary. At the bottom: readers see an older score, then a simpler page, never an error.0. Normalupdates in 3 to 8 s1. Slow downpoll every 15 s2. Shed widgetsgraphs, photos off3. Streams to pollsstream clients fall back4. Serve stalestale-if-error, age label5. Static snapshotplain HTML every 30 sflags ride inthe summaryreaders see an older score, then a simpler page, never an error
Each step costs readers a little freshness or a few extras, never the score itself. The switches for steps 1 and 2 ride inside the live summary as pollMs and flags, so they reach every phone within one poll.
StepTriggerWhat readers notice
0. NormalAll healthyUpdates in 3 to 8 seconds
1. Slow downEdge or origin load highpollMs raised to 15 s; updates in up to 20 seconds
2. Shed heavy widgetsFinal overs of a huge match, or CPU alarmsGraphs, photos and scorecard auto-refresh switched off by flags; score and commentary stay
3. Streams to pollsStream tier full or failingStream clients get 503 with Retry-After and switch to polling; a few seconds slower
4. Serve staleOrigin downEdges serve the last copy under stale-if-error; the strip says "Updated 2 min ago"
5. Static snapshotPage rendering brokenA 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 example match-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.

MinutesWhat to doWhat to draw or say
0 to 6ScopeReaders, peak minute, freshness target, anonymous or not, devices, languages
6 to 12The load shape and the estimateSpikes at wickets; 20 million / 5 s = 4 million requests per second; a ball every 40 s
12 to 25DeliveryBroadcast vs conversation; polling vs SSE vs WebSocket with numbers; TTL, collapsing, shield, jitter, pollMs
25 to 35Data model and client syncEvent log with seq; corrections as events; the four apply rules; the state machine
35 to 45The pageSSR with the score in HTML, islands, commentary pill, scorecard and graphs on demand
45 to 55Phones, failure, accessData per match, hidden tabs, spoiler delay, degradation steps, announcer and pause
55 to 60Wrap upRecap 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?

Think first

Follow-up: "How would you know on match day that it is working?"

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 pollMs follows 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.

Junior 0 of 4
Mid-level 0 of 5

References

Concepts behind it

Try next