cf.completefrontend
My progress
SYSTEM DESIGN · #74

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.

QuestionAnswer we assumeWhat it changes
How many players on one board?Up to 5 million on the weekly global board; friends boards under 500Ranks 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 boardThe 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 timeOne board per window, reset in UTC, labelled in local time
Which scopes?Global, country, and friendsFriends 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 higherThe server must store a tie-breaker, not just a score
Is it a contest with prizes?Weekly prizes for the top 100, so fairness mattersServer-authoritative scores, a short freeze before the end, an audit trail
Which devices?Phones first, plus desktop web and a TV modeSmall 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?

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.

One score change shifts many ranks. Before: rank 30,001 Ana with 2,105, rank 30,002 Ben with 2,100, then many rows, rank 48,212 Cy with 1,841 and rank 48,213 P with 1,840. P scores and reaches 2,102. After: Ana stays 30,001, P is 30,002, Ben drops to 30,003, and Cy drops to 48,213. Every player between P's old and new place, 18,211 of them, drops one rank without scoring.before#30,001 Ana 2,105#30,002 Ben 2,10018,209 more rows#48,212 Cy 1,841#48,213 P 1,840after P scores 2,102#30,001 Ana 2,105#30,002 P 2,102#30,003 Ben 2,100#48,213 Cy 1,841every row: one place down1 score event, 18,212 rank changes
P's score is one number, but P's jump changes 18,212 rank numbers. A client holding a slice in the middle sees every rank in it shift by one, so ranks must come from the server.

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?

On the client, from the full list

Download all scores, sort them, and assign ranks in JavaScript.

Apply score changes to the local array and re-sort.

Pros

  • Instant re-sorting with no server round trip.
  • Easy for small boards like a quiz room or a friends list.

Cons

  • Impossible past a few thousand players: download size, memory and sort time explode.
  • Every client may rank slightly differently while updates arrive in different orders.

SQL query per request

Store scores in a table.

Rank of a player is SELECT COUNT(*) FROM scores WHERE score > :mine; the top is ORDER BY score DESC LIMIT 100.

Pros

  • Uses the database you already have.
  • Fine for boards read rarely, like a monthly report.

Cons

  • Counting rows above a score is linear in many databases: slow at millions of rows and thousands of reads per second.
  • Hot rows and indexes under constant writes.

In-memory sorted set (Redis ZSET)

Keep one sorted set per board: member is the player id, score is the points.

Write with ZADD; read the top with a range; read a player's rank with ZREVRANK.

Pros

  • Rank lookups and updates take O(log N) time, so 5 million players is easy.
  • One shared order: every reader sees the same ranks for the same moment.

Cons

  • Memory: every board keeps every member in RAM, so many boards of millions of players add up.
  • Ties need care: equal scores are ordered by member name, which is not fair.

Periodic precomputed snapshots

A job sorts the whole board every minute and writes rank pages to a cache or CDN.

Clients read static pages by rank.

Pros

  • Very cheap reads; pages can sit on a CDN.
  • Good for huge, slow boards, like an all-time board.

Cons

  • Ranks lag by up to the job interval.
  • Your own rank after you play is stale until the next run.

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.

text
# 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
One weekly board. GT only raises a score, so a late, lower write can't lower it.

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.
StyleScores 900, 850, 850, 800Where you see it
Standard competition1, 2, 2, 4Common in sports results: "two players tied for 2nd, next is 4th"
Dense1, 2, 2, 3Less common; no gaps, so rank 3 means two distinct scores beat you
Ordinal with tie-breaker1, 2, 3, 4Contests: 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.

Ties
WindowAround me
The weekly board with competition ranking. Top 5: #1 Mei 6,120, #2 Arjun 5,980, #2 Lena 5,980, #2 Kofi 5,980, #5 Sara 5,700. Then a gap of 48,202 players. Around you: #48,208 Omar 1,849, #48,209 Ines 1,847, #48,210 Yuki 1,844, #48,211 Diego 1,842, #48,212 Ravi 1,840, #48,212 You 1,840, #48,212 Noor 1,840, #48,215 Tom 1,839, #48,216 Ama 1,837, #48,216 Leo 1,837, #48,218 Zoe 1,834. You are #48,212 of 5,012,334, top 1%.top 5#1Mei 6,120#2Arjun 5,980#2Lena 5,980#2Kofi 5,980#5Sara 5,70048,202 playersweekly boardyou: #48,212 of 5,012,334top 1%around you#48,208Omar 1,849#48,209Ines 1,847#48,210Yuki 1,844#48,211Diego 1,842#48,212Ravi 1,840#48,212You 1,840#48,212Noor 1,840#48,215Tom 1,839#48,216Ama 1,837#48,216Leo 1,837#48,218Zoe 1,834
Tied players share the best rank of their group and the next rank skips: three players on 1,840 are all #48,212, and the next player is #48,215.

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 ZUNIONSTORE on 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?

Top N only

Show ranks 1 to 100 and nothing else.

Pros

  • One request, one subscription, shared by everyone, easy to cache.

Cons

  • Useless to 99.998% of players: they never see themselves.
  • No reason to come back after a run.

Top N plus a "your rank" line

Top 100, then one line at the bottom: "You: #48,213, 1,840 points".

Pros

  • Cheap: one extra ZREVRANK per viewer.

Cons

  • No context: who do I need to pass next, and by how much?

Top N plus an around-me window

Top 10 to 100, a gap row, then the 5 players above and 5 below you.

Your row is pinned when it scrolls out of view.

Pros

  • Shows the goal (the top) and the next step (the player just above you).
  • Each viewer's personal window is only 11 rows.

Cons

  • Two kinds of slice with different update feeds.
  • The gap row must be explained clearly.

Bands for deep ranks

Below some rank, show a percentile band instead of an exact number: "Top 12%".

Pros

  • Motivating for players far down; exact ranks like 3,912,447 feel meaningless.
  • The band changes slowly, so it is cheap to keep live.

Cons

  • Loses precision; competitive players want the exact number.
  • Needs a clear rule for when bands start.

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.

Wireframe of the leaderboard on a phone. At the top, window tabs Daily, Weekly and All-time, with Weekly selected, and scope chips Global, Country and Friends. A status line says Live, resets in 2 days. Then the top 3 rows: Mei 6,120, Arjun 5,980, Lena 5,980. A gap row says 48,208 players. Then the around-me window: rank 48,212 Kofi 1,841, rank 48,213 You 1,840 highlighted, rank 48,214 Sara 1,839; the real window has 5 rows above and below. At the bottom, buttons Jump to me, Go to rank number, and Pause. Notes on the right name the parts, including the pinned copy of your row that appears when your row scrolls out of view.Weekly leaderboardDailyWeeklyAll-timeGlobalCountryFriendsLive, resets in 2 days1 Mei 6,1202 Arjun 5,9803 Lena 5,98048,208 players48,212 Kofi 1,84148,213 You 1,84048,214 Sara 1,839Jump to meGo to #Pausewindow tabs, scope chipsboard id goes in the URLfreshness label: live,stale, frozen or finaltop slice: shared by all,pushed every 2 saround me: 5 up, 5 down(1 each shown), every 5 syour row: pinned to theedge when scrolled away
Two slices share one table: the shared top and the personal window, joined by a gap row that says how far apart they are. The controls come first in the tab order, before the table.

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?

Polling

The client refetches the top and its window every few seconds.

Pros

  • Plain HTTP; the shared top can be cached by a CDN for 1 to 2 seconds, so 200,000 viewers cost the origin a handful of requests.
  • Nothing to reconnect.

Cons

  • Wasted requests when nothing changed.
  • Freshness is capped by the poll interval plus cache age.

Score deltas

Push "player X now has 1,900 points" for players in or near the slice.

Pros

  • Tiny messages.

Cons

  • The client must re-rank, but it only holds a slice: a player entering from outside shifts ranks it can't see.
  • Missing one delta corrupts the board until a full reload.

Slice snapshots

Every tick, the server sends the full ranked slice the client watches, with a version number.

The client replaces its slice and diffs by player id to find moves.

Pros

  • Self-healing: each message is complete, so a lost one does not matter.
  • Ranks and ties come from one place.
  • The top-N snapshot is computed once per board and fanned out to everyone.

Cons

  • Larger messages: 100 rows at about 60 bytes is 6 KB before compression.
  • Bandwidth scales with slice size, so keep pushed slices short.

Snapshot plus row diffs

A full snapshot on subscribe, then each tick only the rows whose rank or score changed, with the version they apply to.

On a version gap, ask for a fresh snapshot.

Pros

  • Small messages on quiet boards.
  • Still server-ranked.

Cons

  • Needs gap detection and resync, like any delta stream.
  • In a busy top 10, almost every row changes anyway.

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.

BoardIntervalWhy
Kahoot-style quizOnce per questionThe reveal is the moment; between questions nothing changes
Top 10 during a live event or final hour1 to 2 sThe story is at the top; movement is the show
Top 100 on a normal day2 to 5 sCalm; few people watch it continuously
Around-me window5 s, plus right after your own runYour neighbours matter, but your own result must feel instant
All-time board30 to 60 s, or pollingIt hardly moves; a snapshot cache is fine
A six-second timeline. Top row: score events arrive constantly, many per second. Second row: the server pushes the top slice at 0, 2, 4 and 6 seconds as versions v40 to v43. Third row: the around-me window is pushed at 0 and 5 seconds, plus at 3.3 seconds right after the user's own run. Bottom row: the top rows reorder only after each top push, with a 350 ms animation, so they move at most once every 2 seconds.score eventstop, every 2 sv40v41v42v43around me, 5 sw7w8w9your run: pushed nowtop rows move0 s2 s4 s6 s
The data changes all the time; the screen changes in calm steps. Your own run is the exception: it gets an immediate push, because waiting for your own result feels broken.

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".

ts
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();
}
Applying a slice snapshot: newest version wins, one apply per frame.

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?

No animation, highlight changes

Render the new order; give moved rows a short background tint and an "up 3" marker.

Pros

  • Simplest; no layout risk.
  • Good default for reduced motion.

Cons

  • Readers lose track of who moved where in a busy top 10.

Absolute positioning with CSS transitions

Every row is position: absolute with transform: translateY(rank * rowHeight) and a transition on transform.

Pros

  • The browser animates for you when the transform changes.
  • Works well with fixed-height rows.

Cons

  • Rows leave normal flow, so it is no longer a plain table layout; text wrapping and variable heights break it.
  • DOM order and visual order can diverge if you don't reorder the DOM too, which confuses screen readers.

FLIP on keyed rows

Record each row's position (First), let React commit the new order (Last), apply the inverse transform (Invert), then animate it to zero (Play).

Pros

  • DOM order always matches visual order, so the table stays a real table.
  • Only transform animates, which the compositor handles without layout work.
  • Full control: duration, stagger, cancel.

Cons

  • Some code: measuring before and after commit.
  • Must handle rows entering and leaving the slice.

View Transitions API

Give each row a unique view-transition-name and wrap the update in document.startViewTransition.

Pros

  • The browser does the FLIP math, including entering and leaving rows.
  • Supported in all major browsers since late 2025.

Cons

  • It animates snapshots of the page, and only one transition runs at a time: a new update mid-animation skips the old one.
  • One name per row: hundreds of names on long boards.

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.

FLIP in four panels for Mei climbing from 3rd to 1st above Ana and Ben. First: measure every row's top while the order is Ana, Ben, Mei. Last: React commits the new order Mei, Ana, Ben. Invert: each row gets a transform that puts it back where it was, Mei translateY plus 84, Ana and Ben minus 42, so nothing appears to move. Play: the transforms animate to none over 350 ms, so Mei slides up and the others slide down.1. FirstAnaBenMeimeasure tops2. LastMeiAnaBencommit new order3. InvertAnaBenMeitranslate back4. PlayMeiAnaBenanimate to noneMei +84px, others -42px350 ms, ease out
The DOM is in the new order from the Last step on, so screen readers and keyboard focus are always right. Only transform animates, which needs no layout.
ts
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)" },
    );
  }
}
FLIP for a list of rows. Call measure() before the update, play() in a layout effect after it. All reads happen before any write.

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:

  1. 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.
  2. 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 scrollTop in the same frame. Browser scroll anchoring (the CSS overflow-anchor property) 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 set overflow-anchor: none on the board and do it ourselves.
  3. 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.

Events
Board
A top 7 board after 0.0 seconds, re-sort on every event. Order: 1 Mei 2,410, 2 Arjun 2,398, 3 Lena 2,391, 4 Kofi 2,380, 5 Sara 2,372, 6 Diego 2,365, 7 Yuki 2,351. You are rank 10, shown in a pinned row. Order changes in the last 10 seconds: 0.#1Mei 2,410#2Arjun 2,398#3Lena 2,391#4Kofi 2,380#5Sara 2,372#6Diego 2,365#7Yuki 2,351You: #10, 2,322pinned: your row is below the top 7time: 0.0 sevents: 0changes/10 s: 0livefull motion
Rows change at most every 2 seconds, but they jump: the markers help, yet passes are hard to follow.

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 IntersectionObserver on the in-list row, with the board's scroll container as the root. No scroll listeners, no layout reads on scroll.
  • Use position: sticky with bottom: 0 so the pinned row sits in the board's own scroll area and never covers the page footer.
  • The pinned row lives outside the table element, 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?

Wait for the server

Show a spinner in the user's row until the server returns score and rank.

Pros

  • Never wrong.
  • Simple.

Cons

  • Feels broken: a game should react within a frame of the run ending.
  • On a slow network, several seconds of spinner on the most exciting moment.

Optimistic score and locally computed rank

Show 2,310 at once and re-sort the around-me window locally.

Pros

  • Instant feedback.

Cons

  • The window holds 11 players: if 2,310 passes all of them, the client can't know where it lands.
  • If the server rejects or adjusts the run, the user sees a rank fall away.

Optimistic score, estimated rank inside the window, authoritative after

Show 2,310 at once, marked "Pending".

If the new score stays inside the loaded window, place the row there; otherwise say "Ranking...".

Replace with the server's score and rank when its reply arrives.

Pros

  • Instant, and honest about what is known.
  • No guessed number that may be wildly wrong.

Cons

  • Two states for the row to show and test.

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.

Sequence diagram of the user's own run, with four lifelines: Game UI, Board store, Game server and Leaderboard. The run ends with 2,310 points; the UI tells the store, which shows the row with 2,310 marked Pending and the rank as Ranking. The UI posts the signed run to the game server at POST /runs, which validates it and writes it to the leaderboard with ZADD GT. The leaderboard returns rank 30,118. The game server replies to the store with rank 30,118 and board version 9123, and the UI renders the rank and announces it. On the failure path, the game server answers 409 run rejected, and the store rolls the row back with an explanation.Game UIBoard storeGame serverLeaderboardrun ends: 2,310Pending rowPOST /runsZADD GTrank 30,118#30,118, v9123render, announce409: roll back
The score is optimistic, the rank never is. The reply carries the board version, so an older window snapshot that arrives later is ignored.

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 machine for board freshness. Connecting goes to Live on the first snapshot. Live goes to Stale after 2 intervals with no message, and Stale goes back to Live on a snapshot. Stale goes to Reconnecting when the stream closes; Reconnecting retries with backoff of 1, 2, 4 seconds and goes back to Live on a fresh snapshot. Live goes to Frozen when the server announces a freeze, and Frozen goes to Final when the server marks the results final. A note says Stale shows the last updated time, turns animations off and dims rows.connectinglivestalereconnectingfrozenfinalfirst snapshot2 intervals silentsnapshotstream closedfresh snapshotretry 1, 2, 4 sserver: frozenserver: finalstale: last updated time,animations off, rows dimmed
Every label on the board reads this one state, so the header, the pinned row and the announcer never disagree about how fresh the data is.
StateEntered whenWhat the user sees
ConnectingThe page opened; no snapshot yetSkeleton rows of the final height, no Live label
LiveA snapshot or heartbeat arrived within 2 intervalsA small "Live" label; "Updated 2 s ago" on hover or focus
StaleNo message for 2 intervals"Last updated 14:02:31", animations off, rows slightly dimmed
ReconnectingThe stream closed; retrying with backoff"Reconnecting..." and the time of the last update
FrozenThe server announced a freezeA freeze banner with the frozen-at time and when results come
FinalThe 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 visibilitychange event), 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.DateTimeFormat in the user's locale and time zone, and relative ages ("2 s ago") with Intl.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.

PartOwnsWhy it is separate
API clientInitial slices, pages by rank, jump-to-rank, score submitPlain HTTP that a cache can serve; retries and errors in one place
Stream clientOne SSE connection per board, heartbeats, reconnect with backoffConnection state changes on its own clock, independent of rendering
Board storeSlices keyed by board id and slice key, each with a version; the user's own entryThe single place where versions are compared and stale data is rejected
Freshness machineLive, stale, reconnecting, frozen, finalOne state that every component reads, so labels never disagree
Board viewTable, rows keyed by player id, FLIP, enter and leaveRendering and motion only; no network code
Pinned rowVisibility of your row, sticky copy, jump to meIts own observer and region, outside the table
AnnouncerPolite messages about the user's own rankScreen reader output needs throttling apart from visual updates
Architecture. At the top, servers: a CDN, the Board API, and sorted sets. Below, the browser in three layers. Network layer: the API client, which gets slices and pages through the CDN, and the stream client, which receives SSE snapshots and heartbeats from the Board API. State layer: the board store with slices and versions, fed by both clients, and the freshness machine with live, stale and frozen, fed by the stream client's heartbeats. View layer: the board view and the pinned row read rows from the store, and the announcer reads the freshness state and the user's own rank.ServersCDNBoard APISorted setsBrowserAPI clientGET slices, pagesStream clientSSE, heartbeatsBoard storeslices, versionsFreshnesslive, stale, frozenBoard viewPinned rowAnnouncertop slicesnapshotspagesslicesheartbeatrowsstate
Network code ends at the store. The view never sees a socket, and the store is the only place that compares versions, so an HTTP page and a pushed snapshot can never overwrite each other in the wrong order.

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.

Data flow. The user's run result goes to the game server, which writes it to the sorted sets with ZADD GT. Every 2 seconds the snapshotter reads the top with ZRANGE and makes a slice with a version, and the edge fans it out. The slice crosses the network as an SSE event to the stream client, which passes only the newest version to the board store. The store diffs by player id and the board view moves rows with a 350 ms FLIP that the user sees. A note says the first load uses GET through the CDN, then the stream keeps it fresh.Game servervalidatesSorted setsone per boardSnapshotterevery 2 sEdgefan-outZADD GTZRANGEslice v918SSE event: sliceStream clientBoard storeBoard viewnewest versiondiff by idFLIP 350 msuserrun resultfirst load: GET via the CDN,then the stream keeps it fresh
Writes go up the left side, reads come down the right and back along the bottom. The browser never writes a score to the board directly.

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.

Sequence diagram of Jump to me, with four lifelines: User, Board view, Board store and Board API. The user taps Jump to me. The view asks the store to jump on this board. The store requests around-me with radius 25. The API replies with 51 rows, 48,188 to 48,238, and a version. The store saves the slice and tells the view, which scrolls, highlights and focuses the user's row. On the failure path, the API answers 404 not ranked, and the view shows Play a run to get ranked.UserBoard viewBoard storeBoard APItap Jump to mejumpToMe(board)GET around-me51 rows, versionslice storedscroll, focus row404 NOT_RANKEDplay to get ranked
Focus moves to the user's row after the scroll, so keyboard and screen reader users land where sighted users look. Not being ranked yet is a normal state with a friendly message, not an error.

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.

ts
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";
Client-side entities. Rank and position come from the server, never computed here.
DataLives inWhy
Selected board and start rankURL queryShareable, survives refresh, back button works
Players by idNormalized storeOne name and avatar per player across slices
Slices with versionsBoard store, in memoryReplaced wholesale; stale versions rejected
Your pending runBoard store, plus sessionStorageSurvives a reload during the reply wait
Freshness stateSmall state machineDrives labels, dimming and animation on or off
Hover, focus, paused queueComponent stateOnly the view cares
Data model. A Slice has a board id, a key of top, me or page, a version, the board total and a list of entries. Each Entry has a player id, a unique position, a displayed rank that repeats on ties, points and a delta. Entries point to Players by id; a Player has an id, name, avatar URL and country, stored once in a normalized store. Below: Me holds the board id, the user's entry and a pending run; Freshness is one state; and the URL holds the board and start rank.SliceboardIdkey: top/me/pageversiontotalentriesEntryplayerIdposition (unique)rank (repeats)pointsdeltaPlayeridnameavatarUrlcountryby playerIdMeentry, pending runFreshnesslive ... finalURL?board=...&from=48201memory, state machine, and the address bar
Slices are replaced whole and compared by version; players are shared by id across slices. Position addresses pages, rank is what people read.

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.

http
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}
Reads, the stream, and its messages.

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?

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 Entry object for any player whose rank and points did not change. Rows are wrapped in React.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-nums on ranks and scores, so 1,111 and 8,888 have the same width and columns don't wobble as numbers change.
  • Give avatars fixed width and height and loading="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.NumberFormat for 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. Use notation: "compact" for "5M players" in headers.
  • Ordinal ranks (1st, 2nd, 3rd) need Intl.PluralRules with type: "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 bdi element (or set dir="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.Segmenter in 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 like padding-inline-start. Up and down arrows stay as they are, since they are vertical.
Two ways to make the board accessible, compared. Left, aria-live on the whole table: every 2 seconds the screen reader queues Mei rank 4, Ana rank 5, Ben rank 6, Kofi rank 9 and more, without end. Right, a silent table plus a polite status region: after the user's run it says Your new rank: 48,101st, up 112, and otherwise at most one message every 30 seconds.aria-live on the tableMei, rank 4Ana, rank 5Ben, rank 6Kofi, rank 9every 2 s, foreverrole="status"Your new rank: 48,101stup 112, after your runothers: at most 1 per 30 stable itself: silentnews about you only
The table stays navigable cell by cell with its headers; only the user's own news is spoken. Use 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:

MinutesWhat you doWhat you draw
0 to 5Ask about board size, update rate, windows, scopes, ties, prizesA short requirements list with numbers
5 to 12Explain why ranks live on the server; sorted set commands; tie ruleA sorted set with top and around-me slices
12 to 20Wireframe: top, gap row, around me, pinned row; paging by rankThe board wireframe
20 to 30Snapshots vs deltas, tick rates, conflation, freshness statesThe architecture and the state machine
30 to 38FLIP on keyed rows, enter and leave, scroll, reduced motion, your own runThe FLIP steps or the submit sequence
38 to 45Accessibility, i18n, performance numbers, then follow-upsNothing 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?

Think first

How is the friends board different from the global one?

Think first

Design the ranking screen of a 50-player live quiz instead. What do you drop?

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.

Junior 0 of 4
Mid-level 0 of 5

References

Concepts behind it

Try next