Design a real-time leaderboard
A ranked list of players that changes while you watch: a top 100, your own rank among millions, daily and all-time boards, friends and country scopes. The hard parts are computing ranks and ties on the server, showing the top and the rows around you, turning a stream of score changes into calm, readable updates, animating rows that move without stealing the scroll, pinning your own row, and staying honest when the data is stale or frozen.
The question
Design a live leaderboard for a mobile and web game with about 5 million weekly players. Players see the top 100, their own rank, and the players around them. There are daily, weekly and all-time boards, and global, country and friends scopes. Ranks should update live while people play.
Walk me through where ranks are computed, how ties work, what the client fetches and subscribes to, how often the screen changes, how rows move without confusing the reader, how your own row behaves, and how you keep it fast and accessible.
Real-life examples: Codeforces and ICPC contest standings, Kahoot live quiz rankings, Strava segment leaderboards, Fantasy sports league tables, Mobile game weekly leaderboards, Sales team dashboards
Requirements: which leaderboard are we building?
A leaderboard is a list of players sorted by score, with a rank number next to each one. A live leaderboard updates while you look at it. You have seen them in many places: Codeforces and ICPC contest standings, the Kahoot ranking between quiz questions, Strava segment boards, fantasy sports league tables, the weekly board in a mobile game, and the sales dashboard on an office TV.
They look alike, but they differ in size, speed and fairness rules. A Kahoot-style quiz room has about 50 players and changes once per question, while a mobile game board can have 5 million players and thousands of score changes per second. A coding contest has penalties and often a scoreboard freeze: the public standings stop changing for the last hour. The first job is to find out which one we are building.
Some neighbouring guides already cover pieces of this, and we link them (see Related at the end) instead of repeating them. The online-judge guide covers contest scoring, penalties and the freeze, and the stock-ticker guide covers server conflation, price flashes and stale labels. The live-comments guide covers batching a fast stream into frames, the virtualized-list guide covers windowing long lists, and the drag-drop-list guide covers the FLIP technique for moving rows. This guide focuses on what is special about rankings.
Start with questions. Each answer changes the design. The middle column is what this guide assumes.
| Question | Answer we assume | What it changes |
|---|---|---|
| How many players on one board? | Up to 5 million on the weekly global board; friends boards under 500 | Ranks must come from the server; the client can never hold the whole board |
| How fast do scores change? | About 2,000 score changes per second at peak across the board | The client can't receive every change; it needs a view-shaped feed |
| Which time windows? | Daily, weekly and all-time, each with its own reset time | One board per window, reset in UTC, labelled in local time |
| Which scopes? | Global, country, and friends | Friends boards are small and per user; global ones are shared |
| How are ties broken? | Higher score wins; on equal score, whoever reached it first ranks higher | The server must store a tie-breaker, not just a score |
| Is it a contest with prizes? | Weekly prizes for the top 100, so fairness matters | Server-authoritative scores, a short freeze before the end, an audit trail |
| Which devices? | Phones first, plus desktop web and a TV mode | Small screens, battery care, a large-text layout |
Functional requirements
- Show the top 100 players with rank, name, avatar, country and score, updating live.
- Show the current user's rank and score at all times, plus the few players just above and below them.
- Switch between daily, weekly and all-time boards, and between global, country and friends scopes.
- Page through the board by rank and jump to any rank, including your own.
- Show when the data was last updated, and say clearly when it is stale or frozen.
- Show a countdown to the reset of the current window.
Non-functional requirements
- Freshness: a score change shows on screen within about 2 seconds at the 95th percentile on a good connection.
- Readability: rows move at most once per update interval, and a reader can follow a row with their eyes.
- Performance: first view of the board (top 100 plus your row) within 1.5 seconds on a mid-range phone on 4G; no long tasks over 50 ms from updates.
- Scale: 200,000 people watching the global weekly board at the same time during the last hour.
- Correctness: ranks and ties are computed in one place; two people never see two different ranks for the same snapshot.
- Accessibility: a real data table, no flood of announcements, and a reduced motion mode.
Out of scope
- How the game computes a score: we receive scores from the game server and trust only those.
- Detailed anti-cheat systems: we cover the basics the frontend touches, not cheat detection itself.
- Contest penalty rules and judge queues: the online-judge guide covers those.
- Prize payouts and reward claiming flows.
Think first
A Kahoot room has 40 players; a mobile game board has 5 million. Which parts of the design change between them, and which stay the same?
With 40 players the server can send the whole board every time, and the client can sort and rank it locally. There is no around-me window, no paging, and no virtualization. With 5 million players the client can only ever see slices: the top N, a window around the user, and pages by rank, all computed on the server.
What stays the same is the presentation: batching updates into readable steps, animating moves, keeping your row visible, and announcing your own rank changes politely. Those are the parts that make a leaderboard feel good at any size.
Check your understanding · Exercise 1
Which answers to the scope questions change the frontend design the most? Select all that apply.
What makes a live leaderboard hard
A leaderboard looks like a simple sorted table. Five things about it drive every later decision.
1. A rank is derived from everyone else
A score belongs to one player. A rank does not: it is the number of players with a better score, plus one. So one score change can change thousands of ranks.
If the player at rank 48,213 jumps to rank 30,002, every player from 30,002 to 48,212 moves down by one. That is 18,211 players who did nothing, and 18,212 rank numbers changed by a single event, counting the mover. You can't send each of those to the browser, and the browser can't compute them without the whole board.
2. You are almost never in the top 100
With 5 million players, 99.998% of them are outside the top 100. A board that only shows the top is a board about other people. The view the user cares about most is a small window around their own rank, which is somewhere in the middle of a huge list. The client needs two very different slices at once: the shared top and a personal window.
3. Change is constant, but eyes are slow
At 2,000 score changes per second, the top 100 alone can reorder several times a second in a busy moment. If every change moved rows on screen, nobody could read a name before it jumped away. The data wants to move fast; the reader needs it to move in calm steps. Choosing that pace is a product decision as much as a technical one.
4. People care about fairness
Ranks often mean prizes, bragging rights or a job offer. A rank that jumps back after you saw it, two friends who see different ranks for the same moment, or a score that looks final but was not, all feel like cheating. The design needs one source of truth, clear tie rules, and honest labels for stale and frozen data.
5. Motion is part of the content
A row moving up is information: "this player just passed three people". Done well, motion helps the reader follow the story. Done badly, rows teleport, the page scrolls under your finger, or the screen flickers. Motion also has to be optional, for people who get dizzy from it.
Check your understanding · Exercise 2
Player P at rank 48,213 gains enough points to reach rank 30,002. What follows? Select all that apply.
Where ranks come from: sorted sets, ties, windows and scopes
The first decision is where a rank is computed. The naive answer is the client: fetch every score, sort, and number the rows. For a 40-player quiz that is fine. For our board it breaks at once: 5 million players at about 40 bytes each is 200 MB of JSON before compression, and the list is stale before it finishes downloading.
Decision Where should the rank of a player be computed?
What we pick: Compute ranks on the server in a sorted set. It gives exact ranks for the top and for any single player in O(log N) time, and it is the one source of truth that every client shares. The client never sorts the global board: it only shows slices the server already ranked. For small scopes such as a 40-player quiz or a friends list the client may re-sort locally, and for the deep all-time board a cached snapshot is fine.
The sorted set in four commands
A Redis sorted set keeps unique members (here, player ids) ordered by a number called the score. The commands we need are below. Ranks from Redis are 0-based, so rank 0 is first place; the API adds 1 before sending them to the client.
ZREVRANK counts from the highest score, which is what a leaderboard wants, and since Redis 7.2 it accepts WITHSCORE to return the score in the same call. ZREVRANGE still works but has been deprecated since Redis 6.2 in favour of ZRANGE with REV. Boards that add up points across runs, instead of keeping the best run, use ZINCRBY.
# A player finishes a run with 1,840 points
ZADD lb:weekly:2026-W43 GT 1840 player:9f2c
# Top 100 with scores (positions 0 to 99)
ZRANGE lb:weekly:2026-W43 0 99 REV WITHSCORES
# My position (0-based), then 5 above and 5 below
ZREVRANK lb:weekly:2026-W43 player:9f2c # -> 48212
ZRANGE lb:weekly:2026-W43 48207 48217 REV WITHSCORES
# Board size, for "of 5,012,334 players"
ZCARD lb:weekly:2026-W43
Ties: who is third when two players have 1,840?
When two members have the same score, Redis orders them by member name, byte by byte, and a REV read reverses that too. So on a high-score-first board player:ff always beats player:0a on equal points. That is stable but arbitrary, and players notice. The product must choose a tie rule, and the frontend must show it, keeping two separate ideas apart:
- Position: the row's index in the sorted list, always unique (1, 2, 3, 4). Used for paging and for keys of pages, never shown as "rank" when ties exist.
- Displayed rank: the number a person reads. With ties it can repeat, and the style decides what comes after a tie.
| Style | Scores 900, 850, 850, 800 | Where you see it |
|---|---|---|
| Standard competition | 1, 2, 2, 4 | Common in sports results: "two players tied for 2nd, next is 4th" |
| Dense | 1, 2, 2, 3 | Less common; no gaps, so rank 3 means two distinct scores beat you |
| Ordinal with tie-breaker | 1, 2, 3, 4 | Contests: equal points split by penalty or by who got there first |
Our game uses a tie-breaker: on equal points, whoever reached that score first ranks higher. A common trick packs both into one sorted set score. With a weekly window of 604,800 seconds (fits in 20 bits, which hold up to 1,048,575), store points * 2^20 + (windowEnd - reachedAt). Earlier arrivals have more time left, so they sort higher.
Redis scores are doubles, which hold integers exactly up to 2^53 (about 9.0e15). A score of 10 million points times 2^20 is about 1.05e13, far below that. ZADD ... GT still works on packed scores: more points always give a bigger number, and the same points reached later give a smaller one, so the earlier time is kept. The client receives points and rank separately and never sees the packed number.
If the product wants standard competition ranks instead (the sports-style "tied for 2nd"), the server computes them as one plus the count of players with strictly more points: with packed scores that is ZCOUNT key min +inf where min is (points + 1) * 2^20, an O(log N) count. Dense ranks are harder, because they count distinct scores, so you would keep a second set of distinct scores. Either way the server sends the displayed rank; the client never derives it from positions.
Time windows and scopes
A time window is the period a board counts: today, this week, all time. The simplest model is one sorted set per window instance, named by the window: lb:daily:2026-10-21, lb:weekly:2026-W43, lb:alltime. Each new score writes to all three. When a window ends, a new key starts empty, and the old one gets an expiry so it is kept for a while for "last week's winners".
- Reset times are in UTC on the server. The client shows them in the user's time zone with
Intl.DateTimeFormat, plus a countdown: "Resets Monday 05:30" in India is the same instant as "Monday 00:00 UTC". - Rolling windows ("last 7 days") are harder: a player's score must drop as old runs expire. You would keep one set per day and merge seven of them with
ZUNIONSTOREon a schedule, so a rolling board lags by the merge interval. - Country scope is one more sorted set per country. A score write touches one set per window and scope, so 3 windows times 2 scopes is 6 small writes.
- Friends scope is different: it is per user and small. The server reads the friends' scores with
ZMSCORE(one call for up to a few hundred ids), sorts them in memory, and returns the whole list. At this size the client can safely re-sort it locally too.
Check your understanding · Exercise 3
Scores are 900, 850, 850, 850, 700. Which statements are right? Select all that apply.
What to show: the top, the rows around you, and pages by rank
Now we know the server can answer "top 100" and "rank of player X" fast. The next question is which slices the screen shows. The naive design is one long list from rank 1 with infinite scroll.
Our median player sits near rank 2.5 million. At 48 pixels a row, that is 120 million CSS pixels of scrolling, about 32 km at 1/96 of an inch per pixel. Nobody scrolls that far; they leave.
Decision Which slices should the main board show?
What we pick: Show the top plus an around-me window, and pin the user's own row. On the main screen, show the top 10 (the full top 100 is one tap away), a gap row that says how many players sit between, and 5 rows above and 5 below the user. Show the exact rank always, and add a percentile badge such as "Top 1%" when the user is below rank 1,000, because a band motivates where an exact number doesn't.
Paging by rank
For most lists, cursor pagination beats offset pagination, because rows inserted at the top shift every offset. A leaderboard is the exception: the rank is the address. "Show ranks 48,201 to 48,250" is exactly what a user asks for, and a sorted set answers it in O(log N + 50). So the API takes a start position and a count: GET /boards/weekly-global/entries?from=48201&count=50.
The catch is that the board moves while you page. Between loading page 1 and page 2, a player may climb from page 2 to page 1, so you see them twice, or drop from page 1 to page 2, so you see them on neither. Two fixes work together.
First, the client stores rows by player id, so a duplicate replaces the older copy instead of adding a second row. Second, every page carries a board version, and the client shows "Ranks have changed, refresh" if a later page is more than a few versions newer. On a live board that is honest; perfect consistency across pages would need frozen snapshots, which we only keep for the freeze period.
Jump to my rank, or to any rank
- A "Jump to me" button asks the server for
ZREVRANK, loads the page around that position, scrolls there, highlights the row for 2 seconds, and moves keyboard focus to it. - A "Go to rank" field takes a number, clamps it to 1 to the board size from
ZCARD, and loads that page. It is the fastest way across millions of rows. - The URL holds the board and the start rank (
?board=weekly-global&from=48201), so a shared link or a refresh lands in the same place.
Very large boards
Why not a virtualized list over all 5 million rows, with a real scrollbar? Virtualization (see the virtualization lesson and the virtualized-list guide) keeps only the visible rows in the DOM, so the row count is not the problem. The scroll height is: 5 million rows at 48 pixels is 240 million pixels, beyond the maximum element height browsers can lay out. Libraries that scale the scrollbar make one pixel of thumb travel jump thousands of ranks, which is useless for finding anyone.
So the full board is paged: windows of 50 rows by rank, with previous and next buttons, the go-to-rank field, and virtualization only inside a loaded range of a few thousand rows. The top 100 and the around-me window are short enough to render plainly.
Check your understanding · Exercise 4
A player ranked 48,213th opens the weekly board. Which requests make sense for the first view? Select all that apply.
Live updates: snapshots, deltas, and a readable pace
The board must change while people play. The naive design forwards every score event to every viewer. At 2,000 events per second and 200,000 viewers, that is 400 million messages per second, nearly all about players nobody on that screen can see. Worse, a score event alone does not tell a client what changed on its screen, because ranks are derived from everyone (chapter 2).
Decision What should the server send to keep a slice live?
What we pick: Push slice snapshots at a fixed tick: every 2 seconds for the top, every 5 seconds for the around-me window, and right away when the user's own score changes. Snapshots are ideal for leaderboards because a rank change is never local: the server ranks, the client only replaces and diffs. Row diffs are a later bandwidth optimization for large slices. Use server-sent events for the stream, since data flows one way (see the server-sent-events lesson); reuse a WebSocket if the game already keeps one open (see the websockets lesson).
Conflation: only the latest snapshot matters
Conflation means merging many updates into the latest one. Snapshots make it trivial: if a client is slow or its connection stalls, the server drops the queued snapshots for that slice and keeps only the newest. A score from 4 seconds ago is worthless once a newer full picture exists. The stock-ticker guide covers conflation on the server in depth; the leaderboard version is simpler because each message replaces the last.
Pace: fresh enough, slow enough to read
Reading a row takes time: find a name, read a number, notice where it sits. If rows reorder every 100 ms, the eye never gets to land before the target moves again. A board that reorders 10 times a second is a flicker. So the update interval is a reading decision first, and a freshness decision second.
| Board | Interval | Why |
|---|---|---|
| Kahoot-style quiz | Once per question | The reveal is the moment; between questions nothing changes |
| Top 10 during a live event or final hour | 1 to 2 s | The story is at the top; movement is the show |
| Top 100 on a normal day | 2 to 5 s | Calm; few people watch it continuously |
| Around-me window | 5 s, plus right after your own run | Your neighbours matter, but your own result must feel instant |
| All-time board | 30 to 60 s, or polling | It hardly moves; a snapshot cache is fine |
The client batches too. Snapshots can arrive in bursts: after a reconnect, two may land in the same frame. The client keeps only the newest version per slice and applies it on the next animation frame, and it never starts a new reorder animation while one is still running. The live-comments guide covers the frame-batching pattern; here the batch is just "newest version wins".
const pending = new Map<string, Slice>();
let frame = 0;
export function onSnapshot(snap: Slice) {
const id = `${snap.boardId}/${snap.key}`; // a slice only means something on its board
const current = store.slices.get(id);
if (current && snap.version <= current.version) return; // old or duplicate
const queued = pending.get(id);
if (!queued || snap.version > queued.version) pending.set(id, snap);
frame ||= requestAnimationFrame(flush);
}
function flush() {
frame = 0;
for (const snap of pending.values()) store.replaceSlice(snap); // diffs by playerId
pending.clear();
}
Check your understanding · Exercise 5
Why do slice snapshots fit a leaderboard better than score deltas? Select all that apply.
Animating reorders without losing the reader
A new snapshot arrives: player Mei moved from 7th to 4th. The naive render sorts the new array and paints it, and two things go wrong. If rows are keyed by index (key={i}), React keeps the DOM node at index 3 and swaps its text: the name under your eye changes, and a hovered or focused row now belongs to someone else.
If rows are keyed by player id, the DOM nodes move, but they teleport: Mei vanishes from row 7 and appears at row 4, and the reader can't tell who passed whom.
Rule one, before any animation: key every row by player id. That makes each row a stable thing that can move, keep focus, and be diffed. Rule two: a move should look like a move.
Decision How should rows move when the order changes?
What we pick: Use FLIP on rows keyed by player id, for slices up to about 100 rows. It keeps the table semantic, animates only transforms, and lets us control timing exactly. The drag-drop-list guide walks through FLIP itself; here we cover what is special about leaderboards: many rows moving at once, rows entering and leaving, rank number changes, and scroll. View transitions are a fine choice for the small top 10 once you accept their one-at-a-time rule.
transform animates, which needs no layout.const first = new Map<string, number>();
export function measure(rows: Map<string, HTMLElement>) {
first.clear();
for (const [id, el] of rows) first.set(id, el.getBoundingClientRect().top);
}
export function play(rows: Map<string, HTMLElement>, reduced: boolean) {
if (reduced) return; // swap in place, the marker tells the story
const moves: [HTMLElement, number][] = [];
for (const [id, el] of rows) { // Last: read every new position first
const before = first.get(id);
if (before === undefined) continue; // entering row: fade, see below
const dy = before - el.getBoundingClientRect().top;
if (Math.abs(dy) >= 1) moves.push([el, dy]);
}
for (const [el, dy] of moves) { // Invert and Play: then write
el.animate(
[{ transform: `translateY(${dy}px)` }, { transform: "none" }],
{ duration: 350, easing: "cubic-bezier(0.2, 0, 0, 1)" },
);
}
}
Rows entering and leaving the slice
- A player who climbs into the top 10 from rank 14 had no row on screen. Fade them in at their new place, starting from just below the last row, so they look like they rose from below.
- A player who drops to 11th leaves. Keep the old row for 300 ms, fade it while sliding it down, then remove it. Show "down to 11" in that fading row, so the reader knows they did not vanish.
- Rank numbers change for rows that didn't move on the screen: everyone below an entering row drops one place. Change those numbers without motion; motion is for rows that changed position.
- Moved rows get a marker for one interval: "up 3" or "down 2" as text plus an arrow icon, and a background tint. Never color alone: green and red are hard to tell apart for many people with color vision deficiency (see the visual-accessibility lesson).
Do not steal the scroll
A user is reading rank 60 in the top 100. A snapshot moves rank 12 to rank 80. If the viewport jumps, they lose their place. Three rules keep the page still:
- Keep geometry stable: fixed row height, a fixed number of rows per slice, and banners that overlay instead of pushing content. A pure reorder then never changes the page height, so the scroll position stays valid.
- When content above the viewport really changes size (a page of ranks loads above, the gap row expands), measure the height added above the first visible row and add it to
scrollTopin the same frame. Browser scroll anchoring (the CSSoverflow-anchorproperty) does this automatically, but Safari only added it in version 27 (2026), and the anchor it picks is a row, which in our board may itself move. We setoverflow-anchor: noneon the board and do it ourselves. - Never auto-scroll to follow a row, including your own. The pinned row (next chapter) shows you where you are without moving the page.
One more readability tool: hold updates while the user is interacting. When a pointer is over a row, a row has keyboard focus, or a touch scroll is in progress, the client queues the newest snapshot and shows "Paused while you read, 2 updates waiting". It applies the queue when the interaction ends. A visible Pause button does the same on demand, which also meets WCAG 2.2.2 (Pause, Stop, Hide) for content that updates by itself.
When the user's system asks for less motion (prefers-reduced-motion: reduce, read with matchMedia), skip the FLIP slide entirely: swap rows in place, keep the "up 3" marker and the tint, and make the tint fade rather than move.
Check your understanding · Exercise 6
A top-10 board renders rows with key={index}. Which bugs follow? Select all that apply.
Your own row: pinned, optimistic, and corrected
The row that matters most to every user is their own. Two problems: it is often off screen, and right after a run the user expects to see their new rank at once, while the server takes a moment to decide it.
A pinned row when yours is out of view
When the user's row is in the visible list, it is highlighted in place. When it scrolls out of view, a copy pins to the bottom edge of the board (or the top, if their row is above the viewport): "You: #48,213, 1,840 points, up 112 today". Tapping it runs "Jump to me". On boards where the user is not loaded at all (they are on page 1 of the top 100 and rank 48,213), the pinned row is always visible.
- Detect visibility with an
IntersectionObserveron the in-list row, with the board's scroll container as the root. No scroll listeners, no layout reads on scroll. - Use
position: stickywithbottom: 0so the pinned row sits in the board's own scroll area and never covers the page footer. - The pinned row lives outside the
tableelement, so screen readers don't meet a duplicate row. It is a labelled region with a link to the in-table row. - Reserve its height at all times so it doesn't push content when it appears; show and hide it with opacity, not layout.
Optimistic score, authoritative rank
The user finishes a run with 2,310 points. The game client knows the score, but the score is only a claim until the server validates it, and the rank depends on 5 million other players. What should the board show in the second or two before the server answers?
Decision What does the user's row show right after their own run?
What we pick: Show the score optimistically with a Pending marker, estimate the rank only when the new score stays inside the loaded window, and let the server's reply replace both. The optimistic-updates lesson covers the general pattern; the leaderboard twist is that the score can be optimistic but the rank should not be invented.
When the server disagrees
- Adjusted score: the server applies rules the client doesn't know (a daily cap, a bonus). The row animates to the server's number with a short note: "Score adjusted: daily cap 2,000".
- Rejected run: validation failed, or the run ended after the window reset. Roll the row back to the last confirmed score, keep the user's run visible in their history, and explain why in plain words. Silently dropping a score is the fastest way to lose a player's trust.
- Rank different from the estimate: the server wins without comment; the difference is just other players moving in the meantime.
- Order races: a 5-second window snapshot computed before the run can arrive after the server's reply. Every snapshot and reply carries the board version, and the client ignores anything older than what it already shows for that slice.
Check your understanding · Exercise 7
The user's run scores 2,310. Their around-me window shows neighbours from 1,790 to 1,890. What should the client show before the server replies? Select all that apply.
Fairness, freezes and honest staleness
A leaderboard is a public claim: "these are the standings right now". When there are prizes, users read it the way they read a bank balance. This chapter covers the parts of fairness the frontend touches, and how the screen stays honest when it can't be live.
The server owns every score
- The browser never writes to the leaderboard. It reports a game result to the game server, which validates it and then writes the score. A
POST /scores { points: 999999 }endpoint open to clients would be abused within hours of launch. - Validation depends on the game and is its own field. Common basics: a signed session that ties a result to a real run, plausibility checks (no more points per second than the game allows), rate limits per account, and for high-stakes boards, replaying the recorded inputs on the server. We name these so the interviewer knows we know they exist; we don't design them here.
- Suspicious scores can be held back: stored, but hidden from public boards until reviewed. The cheater still sees their own score, so they get no signal about detection. The frontend just shows what the server returns.
- The client never sends a rank. Ranks are read-only results.
Freezes and final results
Some contests freeze the public board near the end: the ICPC World Finals, for example, stop updating the public standings for the final hour and reveal the winner at the closing ceremony. The online-judge guide covers how contest freezes work. For the leaderboard client, a freeze is just a state the server announces: the board shows a banner ("Standings frozen at 17:00, results at 18:30"), stops animating, and shows the user's own post-freeze results only to them, marked as hidden from others.
Our weekly game board has a quieter version: when the week ends, prizes go out only after checks. So after the reset the old board shows "Final after review, Tuesday 12:00 UTC" and only later "Final". The UI never calls a board final before the server says so.
Last updated, stale and reconnecting
The stream can fail: a phone goes through a tunnel, a laptop sleeps, a server restarts. The worst outcome is a board that silently stops while still looking live. The fix is to treat freshness as a state the UI shows, driven by a simple rule: the server sends a snapshot or a heartbeat at least every interval, so silence for two intervals means something is wrong. An interval here is the push period of the slice: 2 seconds for the top, 5 seconds for the around-me window.
| State | Entered when | What the user sees |
|---|---|---|
| Connecting | The page opened; no snapshot yet | Skeleton rows of the final height, no Live label |
| Live | A snapshot or heartbeat arrived within 2 intervals | A small "Live" label; "Updated 2 s ago" on hover or focus |
| Stale | No message for 2 intervals | "Last updated 14:02:31", animations off, rows slightly dimmed |
| Reconnecting | The stream closed; retrying with backoff | "Reconnecting..." and the time of the last update |
| Frozen | The server announced a freeze | A freeze banner with the frozen-at time and when results come |
| Final | The server marked the window final | "Final results" and no live label |
- After a reconnect, fetch fresh snapshots and apply them without FLIP if many rows moved: after two minutes away, animating 40 moves at once is noise. Cross-fade instead.
- When the tab is hidden (the Page Visibility API's
visibilitychangeevent), close the stream or ask for a slower rate. When it is visible again, fetch a snapshot first, then resume. - Show clock times with
Intl.DateTimeFormatin the user's locale and time zone, and relative ages ("2 s ago") withIntl.RelativeTimeFormat, both covered in the locale-formatting lesson.
Check your understanding · Exercise 8
Which of these keep the board fair and honest? Select all that apply.
Architecture, data model and API
With the decisions made, here are the parts of the client and what each one owns. The guiding split: the feed layer turns a network stream into versioned slices, the board store holds slices by board, and the view turns slices into calm, animated rows. Each can be tested alone.
| Part | Owns | Why it is separate |
|---|---|---|
| API client | Initial slices, pages by rank, jump-to-rank, score submit | Plain HTTP that a cache can serve; retries and errors in one place |
| Stream client | One SSE connection per board, heartbeats, reconnect with backoff | Connection state changes on its own clock, independent of rendering |
| Board store | Slices keyed by board id and slice key, each with a version; the user's own entry | The single place where versions are compared and stale data is rejected |
| Freshness machine | Live, stale, reconnecting, frozen, final | One state that every component reads, so labels never disagree |
| Board view | Table, rows keyed by player id, FLIP, enter and leave | Rendering and motion only; no network code |
| Pinned row | Visibility of your row, sticky copy, jump to me | Its own observer and region, outside the table |
| Announcer | Polite messages about the user's own rank | Screen reader output needs throttling apart from visual updates |
Data flows in two directions. Reads: the API client loads the first slices with plain GET requests (the shared top can be cached at the edge for a second or two), then the stream client keeps them fresh with snapshots. Writes: the game reports a result to the game server, never to the board; the board learns of it like everyone else, plus a direct reply to the player who scored.
The most common interaction after playing is "where am I now?". The jump-to-me flow touches every layer: a button, the API, the store, the scroll container and focus. The figure below follows it, including the case where the user has no score yet on this board.
Check your understanding · Exercise 9
Why does the board store, not the view, compare snapshot versions? Select all that apply.
Data model
The client never holds "the leaderboard". It holds a few slices, each a ranked run of entries from one board, with the version the server computed it at. Players are stored once by id, because the same player can appear in the top slice, a page, and the around-me window.
type BoardId = `${"daily" | "weekly" | "alltime"}:${"global" | string}:${string}`;
// e.g. "weekly:global:2026-W43", "daily:IN:2026-10-21", "weekly:friends:2026-W43"
type Player = { id: string; name: string; avatarUrl: string; country: string };
type Entry = {
playerId: string;
position: number; // 1-based, unique; used for paging
rank: number; // displayed rank; repeats on ties
points: number;
delta?: number; // places moved since the previous snapshot
};
type Slice = { boardId: BoardId; key: "top" | "me" | `page:${number}`;
version: number; entries: Entry[]; total: number; computedAt: string };
type Me = { boardId: BoardId; entry: Entry | null; pending?: { points: number; runId: string } };
type Freshness = "connecting" | "live" | "stale" | "reconnecting" | "frozen" | "final";
| Data | Lives in | Why |
|---|---|---|
| Selected board and start rank | URL query | Shareable, survives refresh, back button works |
| Players by id | Normalized store | One name and avatar per player across slices |
| Slices with versions | Board store, in memory | Replaced wholesale; stale versions rejected |
| Your pending run | Board store, plus sessionStorage | Survives a reload during the reply wait |
| Freshness state | Small state machine | Drives labels, dimming and animation on or off |
| Hover, focus, paused queue | Component state | Only the view cares |
The API
Reads are plain HTTP so they can be cached. The top slice gets Cache-Control: public, max-age=1 so a CDN absorbs most of the 200,000 viewers; personal slices are private, no-store. The stream sends complete slices with versions, plus heartbeats.
The heartbeat is a named event, not an SSE comment line (one starting with a colon). EventSource drops comment lines before any script sees them, so a comment keeps proxies from closing the connection but can't tell the freshness machine that the server is alive.
GET /boards/weekly:global:2026-W43/top?count=10
GET /boards/weekly:global:2026-W43/around-me?radius=5
GET /boards/weekly:global:2026-W43/entries?from=48201&count=50
GET /boards/weekly:global:2026-W43/stream?slices=top,me (text/event-stream)
event: slice
id: 918223
data: {"key":"top","version":918223,"total":5012334,"entries":[...]}
event: state
data: {"state":"frozen","frozenAt":"2026-10-25T23:50:00Z","resultsAt":"2026-10-27T12:00:00Z"}
event: heartbeat
data: {"version":918223}
Errors use one shape: { code, message, retryAfter? }. 404 NOT_RANKED from around-me means the user has no score on this board yet, which the UI shows as "Play a run to get on the board", not as an error. A from past the end returns an empty page and the real total, so the client can clamp.
Think first
SSE lets the browser resend the last event id on reconnect, as the Last-Event-ID header. Should the server replay missed snapshots from that id?
No. Snapshots replace each other, so replaying 30 missed ones would only animate history. On reconnect the server sends the current snapshot of each subscribed slice, and the client applies it if its version is newer. The id is still useful: if it equals the current version, the server can skip sending anything.
Check your understanding · Exercise 10
Which fields should the client get from the server rather than compute? Select all that apply.
Performance, accessibility and internationalization
A leaderboard repaints for its whole life, often on a phone, often while the game runs in the same tab. The work per update must be tiny, the screen reader story must not be a flood, and names come from everywhere in the world.
Render only what changed
- When the store replaces a slice, it reuses the previous
Entryobject for any player whose rank and points did not change. Rows are wrapped inReact.memo, so unchanged rows skip rendering entirely. In a typical 2-second top-10 tick, two to four rows change. - FLIP reads first and writes after: read all row positions in one pass, commit, read again in one pass, then start every animation. Mixing reads and writes per row forces a layout per row (layout thrashing, see the runtime-performance lesson).
- Animate only rows inside the viewport plus a small margin; rows off screen just take their new place.
- When the user taps a tab or a filter while an animation runs, finish it at once (
animation.finish()) so the interaction gets the next frame. That protects Interaction to Next Paint (INP), whose good threshold is 200 ms. - Use
font-variant-numeric: tabular-numson ranks and scores, so 1,111 and 8,888 have the same width and columns don't wobble as numbers change. - Give avatars fixed
widthandheightandloading="lazy"below the fold, so images never shift rows (Cumulative Layout Shift) and FLIP measurements stay valid.
A table first, announcements second
The board is a real table with a caption ("Weekly global leaderboard, 5,012,334 players"), column headers th scope="col" for Rank, Player and Points, and the player name as the row header (th scope="row"). Screen readers then read each cell with its headers, so moving along a row gives something like "Mei, Points, 2,410". The screen-readers lesson covers table navigation in depth.
The order is fixed by score, so there is no aria-sort. Add it to the sorted column header only if users can sort the columns themselves.
The tempting mistake is aria-live on the table. Every snapshot would then read out a list of changes, every 2 seconds, forever. Instead, the board itself is silent, and a separate polite role="status" region, present in the page from load, announces only what matters to this user: their own confirmed rank after a run ("Your new rank: 48,101st, up 112"), and at most one rank change per 30 seconds otherwise. Freeze and stale state changes are announced once.
- Focus follows the player, not the position: because rows are keyed by id, a focused row keeps focus when it moves. While a row has focus, updates pause (see the animation chapter), so it doesn't move under the user at all.
- Jump to me, Go to rank and Pause are real buttons and inputs in the tab order, before the table.
- Movement markers use text and an icon ("up 3"), never color alone; the moved-row tint has 3:1 contrast against the row background.
- Reduced motion swaps rows in place, as covered earlier. The Pause button helps everyone who finds a moving list hard to read.
Numbers, names and scripts
- Format every number with
Intl.NumberFormatfor the user's locale: 48,213 in en-US, 48.213 in de-DE, and 50,12,334 players in en-IN, which groups in lakhs. Usenotation: "compact"for "5M players" in headers. - Ordinal ranks (1st, 2nd, 3rd) need
Intl.PluralRuleswithtype: "ordinal"to pick the right message for each locale; many languages use a different pattern, so the translated string decides. - Names come in every script. In the pinned row, in announcements, and in compact mobile rows where rank, name and points share one line, wrap each name in a
bdielement (or setdir="auto") so an Arabic or Hebrew name doesn't reorder the text around it. Table cells are already separate blocks, so they are safe. Strip bidi control characters such as U+202E on the server, or a name can visually spoof another. - Truncate long names in CSS with
text-overflow: ellipsis, and if the server shortens names, it cuts by grapheme (Intl.Segmenterin JavaScript), never in the middle of an emoji or a Devanagari cluster. - Show country flags as images with the country name from
Intl.DisplayNames. Flag emoji don't render as flags on Windows. - In RTL locales the whole table mirrors with
dir="rtl"; use logical properties likepadding-inline-start. Up and down arrows stay as they are, since they are vertical.
role="status", which is polite by default.Check your understanding · Exercise 11
Which accessibility choices fit a live leaderboard? Select all that apply.
Running it in the interview
In 45 minutes you can't cover every chapter in depth. Spend the time where the interviewer's signal is: ranks as derived data, the top plus around-me view, update pacing, and motion. A plan that works:
| Minutes | What you do | What you draw |
|---|---|---|
| 0 to 5 | Ask about board size, update rate, windows, scopes, ties, prizes | A short requirements list with numbers |
| 5 to 12 | Explain why ranks live on the server; sorted set commands; tie rule | A sorted set with top and around-me slices |
| 12 to 20 | Wireframe: top, gap row, around me, pinned row; paging by rank | The board wireframe |
| 20 to 30 | Snapshots vs deltas, tick rates, conflation, freshness states | The architecture and the state machine |
| 30 to 38 | FLIP on keyed rows, enter and leave, scroll, reduced motion, your own run | The FLIP steps or the submit sequence |
| 38 to 45 | Accessibility, i18n, performance numbers, then follow-ups | Nothing new; talk through the table and the announcer |
Say the core insight early, in one sentence: "A rank depends on everyone, so the client only shows slices the server ranked, and it receives whole slices, not score events." Every later choice follows from it, and it separates you from candidates who start sorting arrays in the browser.
Follow-up questions to expect
Think first
The top 10 now has 2 million viewers during a live final. What changes?
The shared top snapshot is the same for everyone, so fan it out: compute it once per tick, publish it to many edge or socket servers, and let each push it to its own clients. Clients that can't keep a socket fall back to polling a CDN-cached URL with max-age=1. The personal around-me window slows to every 10 seconds during the peak, since it costs per viewer.
Think first
How is the friends board different from the global one?
It is small (under 500) and personal. The server reads all friends' scores in one call and returns the whole ranked list, so the client has every row and can re-sort locally on a friend's score event. There is no around-me window or paging, and the client can animate every move, because each one is a friend the user knows.
Think first
Design the ranking screen of a 50-player live quiz instead. What do you drop?
Drop sorted sets, paging, virtualization and around-me windows: the server sends all 50 rows after each question. Keep the reveal: show the old order, then animate to the new one with FLIP over about a second, highlight the top movers, and announce the user's own new rank. The pace is set by questions, not by a timer.
Check your understanding · Exercise 12
An interviewer asks: "Why not just send every score change over a WebSocket and sort on the client?" Which points belong in your answer? Select all that apply.
Summary
- A rank is derived from every other score, so ranks are computed on the server (a sorted set gives O(log N) rank and range reads) and the client only shows ranked slices.
- Keep position (unique, for paging) and displayed rank (may repeat on ties) as separate fields, and let the server decide ties.
- Show the top plus an around-me window and a pinned row: almost no one is in the top 100, and the next player to pass is the most useful row on the screen.
- Push whole slice snapshots with versions at a readable pace (1 to 5 seconds), conflate to the newest, and reject anything older than what is shown.
- Key rows by player id and animate moves with FLIP on transforms; never steal the scroll, pause while the user interacts, and swap in place for reduced motion.
- Show the user's own score optimistically, but never invent a rank; the server's reply replaces both.
- Freshness is a visible state: live, stale, reconnecting, frozen, final. A board that looks live but isn't breaks trust.
- A real table, a silent board and a polite announcer for the user's own rank; locale-formatted numbers and isolated names.
Check your answer
Tick what your own design covered. Pick the level you're interviewing for: each level includes the ones before it.
References
- Redis sorted sets The data type behind most leaderboards, with a leaderboard example.
- ZREVRANK command 0-based rank from the highest score, O(log N); WITHSCORE since 7.2.
- ZRANGE command Ranges by index with REV; equal scores ordered by member name.
- Ranking (Wikipedia) Standard competition, dense, ordinal and other tie styles.
- FLIP your animations Paul Lewis's original write-up of First, Last, Invert, Play.
- Using server-sent events (MDN) Event streams, event ids and reconnection.
- overflow-anchor (MDN) Browser scroll anchoring and how to opt out.
- prefers-reduced-motion (MDN) Detecting a request for less motion, in CSS and with matchMedia.
- Table pattern (WAI-ARIA APG) Table semantics, headers, and when aria-sort applies.
- Understanding WCAG 2.2: Pause, Stop, Hide Why auto-updating content needs a pause control.
- Intl.NumberFormat (MDN) Locale-aware number formatting, including compact notation.
Concepts behind it
- WebSocketsOne long-lived connection, opened by an HTTP handshake, on which browser and server can each send a message at any moment. The usual pick for chat, multiplayer, and collaboration, and the one where you own the protocol, reconnects, and scaling.
- 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. - 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.
- Runtime performanceKeeping the page responsive after it loads: finding long tasks on the main thread, splitting or moving that work, and avoiding needless re-renders so every click and keypress paints in under 200 ms.
- Optimistic updatesShowing the result of a user's action at once, before the server confirms it, and quietly undoing it if the server says no: the trick that makes likes, checkboxes, and chat messages feel instant on any network.
- 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.
- Color, contrast, and motionHow people with low vision, color blindness, motion sensitivity, or custom system themes see your UI, and the rules that keep it usable: contrast ratios, never color alone, reflow at 400% zoom, target size, and the user preference media queries.
- Dates, numbers, and pluralsTurning raw instants, numbers, amounts, and counts into text that reads right for each user with the browser's built-in Intl APIs, instead of hand-built strings that are correct for only one country.