System designCore10 min
Counters & leaderboards
A like count and a top-ten list look like the simplest features in an app, until a million people press the same button. Counters become hot keys, retries count twice, and ranking millions of scores on every request is too slow. The fixes are to record facts and derive the counts, batch and split the writes, and keep rankings in a structure that is always sorted.
1 · The problem
A popular counter is one hot key, and every press is a write to it
A daily word puzzle has 2 million players. Each day's puzzle shows how many people solved it and how many liked it, and a leaderboard ranks the day's fastest solves. The first version stores the like count as a number on the puzzle's row and adds one on every press. On a normal day, likes arrive a few at a time. The morning a celebrity shares the puzzle, they arrive at 400 a second, all to the same row.
That one row now behaves like any hot key (Hot partitions & skew): every increment waits for the one before it, and the row's limit becomes the feature's limit. Some databases state the limit outright. A single Firestore document can't be updated at an unlimited rate; a counter shard there is sized for about one increment a second. At 400 likes a second, a single-document counter is about 400 times over budget.
Three different problems hide in "just count it":
- Correctness: a like must count once, even when the app retries, and an unlike must remove it.
- Write load: popular items concentrate writes on one key.
- Ranking: a top-ten list, and "you are 48,212th", must be answered for millions of players without sorting on every request.
Problem | What goes wrong in the first version |
|---|---|
Correctness | A like retried by the app counts twice; an unlike can't find what to remove |
Write load | 400 likes a second queue up on the puzzle's one row |
Ranking | Showing "you are 48,212th" means sorting 2 million solves on every request |
2 · Facts first
Record who did what, and treat the number as derived
An increment is not idempotent. An atomic counter, DynamoDB's for example, increments again on every call, so a retried request overcounts. A like button retried after a timeout, a double tap, or a client that resends after reconnecting all add a second like from the same person, and an unlike can't tell which increment to undo.
The fix is to store the fact, not just the number. A like is a row keyed by (puzzle, player), so the database itself refuses a duplicate, and an unlike deletes that row. The count is derived from those rows. Kept that way, the counter can be wrong, late or lost, and it can always be recomputed:
What is counted | Source of truth | The displayed number |
|---|---|---|
Likes | One row per (puzzle, player) | Derived; may lag by seconds |
Solves | One result row per (puzzle, player) | Derived; may lag by seconds |
Unique viewers | View events | Estimated with HyperLogLog: counting distinct things |
Stock or money | The count itself, updated atomically | Must be exact (Inventory & booking contention) |
Most social counts are shown rounded anyway, as "12K likes", so a number a few seconds behind is invisible to players. That tolerance is what makes the next section possible: if the number can lag, the writes that maintain it can be batched.
3 · Batching
Batch increments before they reach storage, and split what's left
The like rows themselves spread across many keys, one per player, so they aren't hot. Only the derived total is. Rather than incrementing it on every like, each application server keeps a small in-memory tally per puzzle and flushes the sum once a second:
Each like is still recorded at once as its own row. Only the count is batched: a server that crashes loses at most a second of increments, which a recount from the rows repairs.
At 400 likes a second: one write per like is 400 writes a second to one key. Batching on 40 servers makes it at most 40, and spreading those over 40 shards gives each about 1 a second, within the per-shard rate Firestore can sustain.
Splitting is the distributed counter: the count is the sum of several shard documents, each incremented by a random writer. The trade-off is this. Write throughput grows linearly with shards, so 10 shards take 10 times the writes; but reading the count means reading every shard, so reads get slower and more expensive as shards are added. Its suggested fix is a separate roll-up document that holds the total, updated at a slower cadence, which clients read instead.
An in-memory store is the other common home for counters. Redis increments are atomic and O(1) in memory, and a counter, one INCR per event, is the most obvious use of them. The cost is durability: treat a Redis counter as the fast copy, flushed to the database or rebuilt from the like rows (In-memory data-structure stores).
4 · Leaderboards
Keep scores in a structure that is always sorted
The daily leaderboard needs three answers fast: the top ten, a player's rank, and the players just above and below. A database query that sorts 2 million results for every request does far too much work; what's needed is a structure that keeps scores ordered as they arrive. Redis's sorted set is the standard tool: unique members, each with a score, kept in order by a skip list alongside a hash table, so adding or updating a member costs O(log N):
Today's leaderboard in a sorted set
ZADD lb:2026-10-03 LT 94.2 player:7731 # solve time in seconds; keep the lowest
ZRANGE lb:2026-10-03 0 9 WITHSCORES # top ten: fastest first
ZRANK lb:2026-10-03 player:7731 # 0-based rank of one player
ZRANGE lb:2026-10-03 48206 48216 # the neighbors around rank 48,211
EXPIRE lb:2026-10-03 691200 # keep for 8 daysEach operation stays cheap as the board grows. Looking up a rank is O(log N); reading a range is O(log N + M) for M entries. For 2 million players, log₂ 2,000,000 ≈ 21 steps, instead of sorting 2 million scores. The LT flag on ZADD only updates a score if the new one is lower, so a player's best time sticks without a separate read.
The sorted set's rules have consequences worth knowing:
- Ties sort by member name. When two members have the same score, Redis orders them lexicographically, so player:1234 beats player:9876 on an equal time. To break ties by who finished first, fold the finish time into the score at a smaller scale: the solve time in tenths × 10^10, plus the finish timestamp in seconds. The time always decides, and the timestamp only separates equal times.
- Direction is a choice. Lower is better for solve times, so ZRANGE from 0 lists the fastest. For points, where higher is better, use the reverse range and reverse rank.
- Time windows are separate keys. A key per day, week or season, with an expiry, so old boards drop out on their own.
The result row is written first and is the record; the sorted set is updated from it and answers the rank in O(log N). If the set update fails, a rebuild from the results table restores it.
The leaderboard is still derived data. Each solve is first recorded as a result row, and the sorted set is updated after; if the set is lost, it's rebuilt from the day's results. That also handles cheating: a disqualified score is deleted from the results and removed from the set with ZREM.
5 · At scale
Exact ranks for the top, approximate ranks for everyone else
A sorted set lives on one Redis node, and every score update for the day goes to that one key. For a single daily board of 2 million players that's comfortable. A global all-time board with hundreds of millions of members, updated by every game, is not: the key is hot and large at once.
Two observations make big boards manageable. The top of the board is small and heavily read, so it can be kept exactly and cached. Players far down rarely need an exact number: "48,212th" and "top 3%" mean the same to them. So large boards often combine three parts:
Part | What it holds | What it answers |
|---|---|---|
Exact top board | The best 10,000 scores in one sorted set | Exact ranks for the top |
Score histogram | Counts of players per score bucket | A percentile: players in better buckets ÷ total |
Regional or friend boards | Small separate sets | The comparisons most players care about |
- Record the result
Write the solve as a result row keyed by (puzzle, player): the source of truth.
- Update the exact board
If the time beats the current 10,000th, ZADD it to the top board and trim the set back to 10,000.
- Update the histogram
Add one to the player's score bucket, a counter that can be batched like likes.
- Answer a rank
Top players get their exact rank from the set; everyone else gets a percentile from the histogram.
Counting the most-played or most-liked items over a moving window, "trending today", is a different problem: there are too many items to keep a counter each in memory. That's the job of approximate top-K structures (Count-Min Sketch & top-K).
6 · The checklist
Store facts, batch counts, keep rankings sorted
For the puzzle game, and most apps with counts and rankings:
Decision | Puzzle game choice |
|---|---|
Source of truth | Like and result rows keyed by (puzzle, player) |
Displayed counts | Derived, batched per server each second, rounded on screen |
Hot counters | Sharded for viral puzzles; a roll-up total for reads |
Daily leaderboard | A sorted set per day, best time kept with LT, expiring after 8 days |
Ties | Finish time folded into the score |
Large boards | Exact top 10,000; percentiles from a histogram below that |
Recovery | Counters and boards rebuilt from the rows |
Check yourself
4 questions
Takeaways
Remember this
- Store each like or result as a fact keyed by (item, user), and derive counts from it, because increments aren't idempotent.
- A popular counter is a hot key: batch increments on the servers, spread them over shards, and read a roll-up total.
- Displayed counts can lag by seconds; stock and money can't, and need atomic exact updates.
- Leaderboards belong in an always-sorted structure such as a Redis sorted set: O(log N) updates and rank lookups, a key per time window.
- For huge boards, keep the top exact and give everyone else a percentile from a score histogram.