SevyDevy

Guide · System design

Design a Gaming Leaderboard with Sorted Sets and Live Ranks

28 min readUpdated By Saket Bhatnagar

Researched and drafted with AI, reviewed and edited by Saket Bhatnagar.

In short

  • Leaderboard system design is building a service that answers two questions fast for millions of players: who is in the top 10, and what is my rank right now.
  • A database table sorts scores well, but one player's rank means counting everyone above them. In our PostgreSQL 16 test, one deep rank among 3 lakh (300,000) players took about 60 milliseconds.
  • A Redis sorted set (Redis is a fast in-memory data store) keeps members in order of a number called the score. Changing a score or finding a rank takes about log N steps, the number of times you can halve N: about 20 for a million players.
  • Ties need a rule you choose on purpose. Packing the points and a database counter that records who got there first into one score makes the sorted set apply that rule for you.
  • Beginners can stop after the tie-breaking section. Engineers get what bites in production: the 2 to the power 53 limit, safe replays and corrections, rebuilds across servers, and exact ranks over shards, pieces of one board.

What goes wrong when everyone checks a game leaderboard at once?

The database runs out of time. Each player's rank needs a count of everyone above them. When lakhs of players ask in the same few seconds, that counting piles up faster than one database can do it.

Picture a common setup. A mobile trivia game runs a live quiz every night at 9 pm. Players answer 12 questions, earn 10 points for each right answer, and climb a global board and a city board. The setup is made up, but the problem is real.

The first leaderboard was one PostgreSQL table (a popular open-source database) with a row per player and their points. The leaderboard screen ran two queries.

  • Top 10: sort all rows by points, highest first, and keep the first ten.
  • My rank: count the rows with more points than mine, then add one.

With 20,000 players, both queries were quick. Then the quiz grew to 3 lakh nightly players, and every player opened the board after every question. That is 3 lakh rank counts in the 15 seconds before the next question.

We ran that rank query on PostgreSQL 16.15 with 3 lakh rows of widely spread points. It had an index on points, a sorted lookup structure like the index of a book. A player at rank 100 cost 0.3 milliseconds. A player at rank 2 lakh (200,000) cost about 60 milliseconds, because PostgreSQL read the whole table to count.

Lakhs of those counts at once pin the database CPU, and the rank screen spins until the next question starts. This guide rebuilds that board step by step: a sorted set for ranks, a fair tie rule, more boards, safe updates and a plan for growth.

What is leaderboard system design?

Leaderboard system design is building a service that keeps players ordered by score while scores keep changing, and answers rank questions fast. The hard part is doing both at once, for millions of players.

A leaderboard answers three kinds of question. Each one stresses the system in a different way.

  • Top N: who are the best 10 or 100 players? Everyone asks the same question, so one answer can be shared.
  • My rank: where am I among everyone? Every player asks about a different row, so nothing can be shared.
  • Around me: who is just above and just below me? This view makes a huge board feel winnable for an average player.

A few words come up again and again. A score is the number you sort by, here the points from correct answers. A rank is your position when everyone is sorted, with 1 at the top.

A board is one ranked list, such as tonight's global quiz or the Hyderabad weekly board. Throughput means how many updates or reads the system handles per second. Latency means how long one request takes.

Before writing new code, the team wrote down what the rebuilt board must do. These targets belong to the made-up setup.

  • Accept about 25,000 score updates a second at the 9 pm peak.
  • Show the top 10 and a player's own rank in under 100 milliseconds.
  • Show a correct answer on the board within 2 seconds.
  • Never lose a player's points, even if a leaderboard machine dies.
  • Run a global board, a board per city, and daily and weekly boards.

Read the fourth goal next to the first two. The board must be fast, but the points must be safe. Those are different jobs, and the design gives them to different parts.

Why can't a normal database table give live ranks?

A table can give live ranks for a small game. For one player's rank it must count every row above that player, and that work grows with the board.

An index on the points column makes the top 10 cheap. In our PostgreSQL test, the query plan, PostgreSQL's step-by-step recipe for a query, walked the index backwards and stopped after exactly 10 rows. The rank count behaves very differently.

For the player at rank 100, the plan scanned the index and visited 99 entries, one per player above. The index finds rows in order, but it does not store how many rows sit above a point. Deep in the board, PostgreSQL chose to read all 3 lakh rows instead.

So the fix needs a structure that stays sorted on every write. It must also know how many entries sit above any point, without visiting them one by one.

How does a Redis sorted set keep players in order?

A Redis sorted set stores each member with a score and keeps members in score order. Adding a member, changing its score and finding its rank each take about log N steps. Redis is an in-memory data store: it keeps data in RAM for speed.

Log N grows very slowly. For 1 million players, log base 2 of N is about 20. Doubling the players adds only about one more step.

What is inside a sorted set

The Redis docs describe two structures holding the same members: a hash table and a skip list. The hash table, a lookup table from a key straight to its value, maps each member to its score. Reading one player's score is a single lookup. The skip list keeps members in score order.

That is the layout for big boards. A sorted set with at most 128 members, each at most 64 bytes, uses a compact list called a listpack instead (Redis 7.0 and later). Redis switches to the skip list once either limit is passed.

A skip list is a sorted linked list with extra express lanes on top. William Pugh introduced it as a probabilistic, or chance-based, alternative to balanced trees, which are search trees that reshape themselves after changes. A random number generator, not strict rebalancing rules, decides how many lanes each entry joins.

Think of Mumbai's local trains. A slow train stops at every station, and a fast train stops only at a few. To reach a small station quickly, you ride the fast train to the last stop before it, then change to a slow train.

In a skip list, the bottom lane is the slow train: it holds every member in order. Each higher lane is a faster train that skips more members. A search rides the highest lane as far as it can without passing the target, then drops down a lane.

Redis adds one detail that makes ranks cheap. Each express link stores its span, the number of members it jumps over. Adding the spans on the way down gives the rank without visiting every member.

Where the picture breaks: train stops are planned, but skip list lanes are random. In Redis's current source code (as of October 2026), each entry has a 1 in 4 chance of joining each next lane up, capped at 32 lanes.

The first version in code

Here is the team's first version in Python, using redis-py, the common Python client for Redis. Each board is one sorted set, and each member is a player id.

python
import redis

r = redis.Redis(decode_responses=True)

def add_points(board, player_id, points):
    return r.zincrby(board, points, player_id)

def top_ten(board):
    return r.zrange(board, 0, 9, desc=True, withscores=True)

def my_rank(board, player_id):
    rank = r.zrevrank(board, player_id)   # 0 is the top, None if missing
    return None if rank is None else rank + 1

def around_me(board, player_id):
    rank = r.zrevrank(board, player_id)
    if rank is None:
        return []
    return r.zrange(board, max(rank - 2, 0), rank + 2, desc=True, withscores=True)

Four calls do the work. zincrby adds points to a player's score, and starts a new player at zero first. zrevrank counts from the highest score, and Redis numbers ranks from 0, so the code adds one for people.

What just happened? We ran this code with redis-py 8.1.0 against Redis 7.0.15 and 8.0.2. A player on 20 points with nine players above came back as rank 10, and a missing player came back as None. around_me for the top player returned three entries, since nobody sits above them.

How to check it yourself

You can watch the same behaviour from the command line with redis-cli, the Redis shell. The comment on each line sums up the reply we got on Redis 7.0.15 and 8.0.2.

bash
redis-cli ZADD lb:demo 70 p:1 60 p:2 20 p:7    # (integer) 3
redis-cli ZREVRANK lb:demo p:7                 # (integer) 2, so rank 3 for people
redis-cli ZRANGE lb:demo 0 9 REV WITHSCORES    # p:1 70, p:2 60, p:7 20
redis-cli OBJECT ENCODING lb:demo              # listpack, the compact small-board form
redis-cli INFO commandstats                    # see usec_per_call on cmdstat_zrevrank

The REV option on ZRANGE needs Redis 6.2 or later. On a board with more than 128 members, OBJECT ENCODING prints skiplist instead.

On a 3 lakh member board, INFO commandstats reported 12 to 34 microseconds of server time per ZREVRANK, across Redis 7.0.15 and 8.0.2 on our shared two-core test machine. The deep PostgreSQL count took about 60 milliseconds, over a thousand times longer. Your numbers will differ, so measure on your own hardware.

How do you break ties on a leaderboard fairly?

You pick a tie rule on purpose, most often that the player who reached the score first ranks higher. Then you pack that rule into the score itself, so the sorted set does the work.

In this quiz, ties are everywhere. Twelve questions at 10 points each allow only 13 possible totals, so thousands of players share 120 on a good night.

The rule is simple. More points always wins, and on equal points, whoever got there first wins. The hard part is deciding who was first, and the phone never gets a say.

About 20. 3 lakh answers in 15 seconds is about 20,000 a second on average, or about 20 every millisecond. Whole seconds would be far worse, and even milliseconds leave many ties.

We simulated this quiz with 80 percent right answers. About 3 in 4 players on 120 points shared their millisecond with another 120-point player. Different game servers also have slightly different clocks, so a millisecond stamp is only as fair as their clock sync.

So the team drops clock time. When the database saves a new total, it also takes the next value from a sequence, a database counter that hands each caller a different number. We call it the acceptance number.

The PostgreSQL docs say sessions always get distinct values. With the default cache setting of 1, which reserves one number at a time, values also come out in the order they were asked for. So a smaller acceptance number means that save asked first, and it wins the tie.

Redis stores every score as a 64-bit floating-point number. ZADD is the command that adds members or updates their scores. Its docs say this format holds whole numbers exactly up to 2 to the power 53, which is 9,007,199,254,740,992. That leaves room to pack two numbers into one.

python
SLOT = 10_000_000_000   # room for 10 billion acceptance numbers

def board_score(total_points, accept_no):
    # More points always wins. On equal points, the smaller number wins.
    if not 0 <= accept_no < SLOT:
        raise ValueError("acceptance number does not fit in SLOT")
    return total_points * SLOT + (SLOT - 1 - accept_no)

def points_of(score):
    return int(score) // SLOT

def save_total(board, player_id, total_points, accept_no):
    score = board_score(total_points, accept_no)
    r.zadd(board, {player_id: score}, gt=True)

The points sit in the high digits and the acceptance number sits in the low digits. Subtracting it from SLOT minus 1 means a smaller number gives a bigger score, so it ranks higher.

Here is a worked example. Player A reaches 120 points with acceptance number 4,000,000, and player B reaches 120 with 4,500,000. A's packed score is 1,209,995,999,999 and B's is 1,209,995,499,999, so A ranks higher.

save_total sends the player's new total, not a small increase. With gt=True, which is ZADD GT in Redis 6.2 and later, Redis replaces a score only with a bigger one. It still adds players it has not seen before.

What just happened? On Redis 7.0.15 and 8.0.2, replaying an older 110-point total for A changed nothing. A newer 130-point total replaced the score. A late or repeated message is harmless while totals only go up.

The app shows points_of(score), so a player sees 120 and never the long packed number.

Two players can no longer share a packed score, because no two saves get the same acceptance number. So Redis never needs its byte-order fallback for these boards. A night has at most 36 lakh saves, so 10 billion numbers last over 2,700 nights.

Some games prefer shared ranks, where everyone on 120 is joint 4th. Then you store points only. A player's shared rank is the number of players with strictly more points, plus one.

ZCOUNT, the Redis command that counts members in a score range, finds that in about log N steps. Writing the lower end as (120 makes the range exclude 120 itself.

Beginners can stop here. You now have the core design: one sorted set per board and a fair tie rule packed into the score. The rest of the guide is for engineers who must run this in production.

How do you run city, daily and weekly leaderboards?

You run each board as its own sorted set with a clear key name, and write each player's total to every board they belong to. Each board gets the total for its own period: the season, the night or the week. A key is the name Redis stores a value under.

  • lb:s42:global for the whole of season 42.
  • lb:s42:city:hyd for players in Hyderabad, with one key for each city.
  • lb:day:2026-10-19:global for one night's quiz only.
  • lb:week:2026-w43:city:hyd for one week in Hyderabad.

One correct answer from a Hyderabad player now updates four boards. So the database keeps three totals per player, one per period. The worker sends all four writes in one round trip using a pipeline, which batches commands so the worker waits only once.

Old daily boards would fill memory forever. So each daily key gets an expiry of 8 days, and each weekly key gets 35 days. TTL, the command that shows a key's seconds left, prints 691200 on a fresh daily key, which is 8 days in seconds.

Once its time is up, a key can no longer be read. The EXPIRE docs say Redis deletes it when a client next touches it, or when a background check that samples keys at random finds it.

Before launching city boards, the team did the memory maths, and so did we. A board of 3 lakh players with 9-character ids used 34.3 MB on Redis 7.0.15 and 8.0.2. That is about 114 bytes per member.

Three lakh players on four boards means 12 lakh (1.2 million) entries, about 137 MB for the boards alone. Leave room for the rest of the server. To check your own board, run MEMORY USAGE with the key name and SAMPLES 0, which prints bytes. Longer player ids cost more.

Time boards also make resets painless. A new week simply writes to a new key. Nothing is cleared at midnight, and last week's final board stays readable until it expires.

How do score updates reach the leaderboard without losing points?

Save points in a durable database first, and feed the sorted sets through a queue. Then a crash in the leaderboard layer never loses points, and the sorted sets become a fast view you can always rebuild.

Redis can lose recent writes. Redis Cluster spreads Redis over several servers, and each main node has copies called replicas. Its specification says replication, keeping those copies up to date, is asynchronous, and there are usually small windows where acknowledged writes can be lost.

Asynchronous means the main node replies to you before its copies have the write. If the main node dies in that gap, the copy that takes over never saw it.

Saving to disk narrows the gap but does not close it. The Redis persistence docs say an append-only log synced every second can lose one second of writes. Snapshots taken every few minutes can lose the last minutes.

Here is the full path. Start at the player's phone, and notice that only the game server decides points.

The leaderboard architecture

Points are saved in the database before they reach any board. Notice that Redis is only fed by the worker, so the boards can be rebuilt from the database.

Diagram connections:

  • Player's phone to Game server (Answer)
  • Game server to Scores database (Save total and event)
  • Game server to Score events queue (Publish event)
  • Score events queue to Leaderboard worker
  • Leaderboard worker to Redis sorted sets (ZADD with GT)
  • Leaderboard worker to Scores database (Read the player's row)
  • Player's phone to Leaderboard API (Ask for my rank)
  • Leaderboard API to Redis sorted sets (Read ranks)
  • Push service to Redis sorted sets (Read top 10)
  • Push service to Player's phone (Live top 10)
  • Player's phone: Sends the chosen answer only
  • Game server: Checks the answer and awards points
  • Scores database: Source of truth: totals plus unsent events
  • Score events queue: One event per changed total
  • Leaderboard worker: Writes each total to every board
  • Redis sorted sets: Global, city, daily and weekly boards
  • Leaderboard API: Top 10, my rank and around me
  • Push service: Sends top 10 changes once a second

The order matters. The game server saves the answer, the new totals, the acceptance number and an unsent-event row in one database transaction, a group of writes that succeed or fail together. Only then does it publish the event.

If publishing fails, a small job finds unsent rows and retries them. This is a form of the transactional outbox pattern, named for the table where events wait until they are sent. A retry can publish an event twice.

Each event names the player whose totals changed. The worker reads that player's current row, the totals and acceptance number, and writes those with GT. Duplicate or late events then do no harm while totals only go up.

The exception is a correction downward, such as a penalty for cheating. GT ignores a lower score, so the team saves the corrected total in the database first, or flags the player for review. The worker skips flagged players.

The correction also sends its own event, and the worker handles it without GT. It lowers the score with a plain ZADD, or deletes a flagged player with ZREM, the command that removes a member.

The worker applies a correction once more a few seconds later. Another worker that read the row just before the change could still write the old total, and the repeat undoes that.

What just happened? We ran this worker against the guide's save_total on Redis 7.0.15 and 8.0.2. A flagged player stayed off the board after their next answer, and a penalty survived a duplicate of the older, higher event.

Rebuilding a board safely

The team also wrote a rebuild job. First the worker starts writing each total to both the live key and a new key. Then the job reads every season total and acceptance number from the database and loads them into the new key, also with GT.

The order matters: an update that landed before the dual writes began is already in the database the job reads. GT on both writers makes the overlap safe, because the bigger, newer total always stays.

Then RENAME swaps the new key into place in one command, so readers never see a half-built board. RENAME deletes the old board as part of the swap, and its docs warn that deleting a very big value can cause high latency.

In Redis Cluster, RENAME works only when both keys sit in the same hash slot, one of the numbered buckets the cluster uses to place keys. We tested it: lb:s42:global and lb:s42:global:new land in slots 15501 and 14703, and RENAME fails with a CROSSSLOT error.

The fix is a hash tag, a part of the key in curly braces that Redis hashes instead of the whole name. Name the new key {lb:s42:global}:new. Only lb:s42:global is hashed, so it lands in slot 15501 with the live key. RENAME then succeeds, and CLUSTER KEYSLOT prints any key's slot.

How do you scale a leaderboard to millions of players?

You add read copies first. Only when one node cannot take the writes do you split a board into several sorted sets and merge their answers. Many games never need the second step.

Here is why one board has a limit. In Redis Cluster, every key maps to one of 16,384 hash slots, and each slot is served by one main node. So a single sorted set always lives on one main node, however big the cluster grows.

Reads are the easy part. Read replicas are copies that follow the main node and can serve the top 10 and rank lookups. In Redis Cluster, a client reads from a replica only after sending READONLY, which accepts possibly stale data.

The top 10 is the same for everyone, so the API also caches it for one second. That one cache absorbs most of the shared reads.

Writes are harder. The game is planning a national tournament with a target of 2 lakh score updates a second (made up), which is more than the team will trust to one node. Measure your own node instead of guessing: redis-benchmark -t zadd -q prints ZADD requests per second.

Splitting one board into shards

The fix borrows the idea behind sharded counters: split one hot thing into pieces, write to one piece, and combine the pieces when you read. Each player id is hashed, turned into a number by a fixed formula, to pick one of 8 smaller boards.

The shard keys must not share a hash tag, or all 8 would land in one slot on one node. Different slots can still share a node, so check the cluster's slot map, which lists the node serving each slot. In the diagram, follow one update into a single shard, then follow the read arrows, which leave every shard.

A leaderboard split into 8 shards

Each player lives on exactly one shard. Notice that a write touches one shard but every global read asks all of them.

Diagram connections:

  • Score update arrives to Hash the player id
  • Hash the player id to Shard 1 board (Hash is 1)
  • Hash the player id to Shard 2 board (Hash is 2)
  • Hash the player id to Shard 8 board (Hash is 8)
  • Shard 1 board to Merge the top 10s
  • Shard 2 board to Merge the top 10s
  • Shard 8 board to Merge the top 10s
  • Shard 1 board to Add up counts above me
  • Shard 2 board to Add up counts above me
  • Shard 8 board to Add up counts above me
  • Merge the top 10s to Global top 10 and my rank
  • Add up counts above me to Global top 10 and my rank
  • Score update arrives: New total and its acceptance number
  • Hash the player id: Picks one of 8 shards
  • Shard 1 board: Players whose hash lands on 1
  • Shard 2 board: Players whose hash lands on 2
  • Shard 8 board: Shards 3 to 7 work the same way
  • Merge the top 10s: 8 short lists, sorted once more
  • Add up counts above me: ZCOUNT on each shard, plus one
  • Global top 10 and my rank: Same answers as one big board

They cannot. A player in the global top 10 has at most 9 players above them anywhere, so at most 9 in their own shard. The API asks each shard for its best 10, sorts those 80 entries, and keeps 10.

Exact rank works by counting. The API reads the player's packed score from their own shard, asks every shard how many members score strictly higher, adds the counts and adds one. That is one ZSCORE, which reads a single score in one lookup, and eight ZCOUNT calls of about log N steps each.

What just happened? We checked both answers on Redis 7.0.15 and 8.0.2 with 3 lakh simulated quiz players over 8 shards. With acceptance numbers, the merged top 10 and all 2,000 sampled ranks matched one big board. With millisecond stamps instead, 1,192 of those 2,000 ranks came out as shared ranks.

The cost is more work on every read and more parts to run. So the team keeps one board for the nightly quiz and uses shards only for the tournament.

Some games skip exact ranks for the long tail. They show a band such as top 12 percent, worked out from counts of players in each score range. Past a point, an exact rank of 841,207 means little to a player.

Ways to answer the two leaderboard questions, and where each one fits
ApproachTop 10One player's rankGood fit
Database table, no indexSorts every rowCounts every row abovePrototypes and tiny games
Database with a points indexFast: walks 10 index entriesVisits every entry above, or reads the whole tableBoards with a few thousand players
One Redis sorted setAbout log N, plus the 10 readAbout log NMost games, up to one node's write limit
Sharded sorted setsMerge each shard's top 10Add ZCOUNT from every shardWrites beyond one node
Score bandsNot used for thisA rough percentile onlyHuge long tails where exact rank adds little

How do players see their rank change in real time?

Players see rank changes in real time when the server pushes small, throttled updates over an open connection, and the app fetches its own rank after each question. Pushing every single change to everyone would swamp the network.

The app keeps a WebSocket open, a connection that stays open so the server can send messages at any time. Once a second, the push service reads the top 10 and sends it only if something changed.

A player's own rank is personal, so it cannot be shared. The app asks the leaderboard API for it after each question closes. With 3 lakh players, that is a burst of 3 lakh small reads.

The team spreads that burst out. Each app waits a random 0 to 2 seconds before asking, which is called jitter. The same reads now arrive over 2 seconds instead of one moment.

A replica, or a worker running behind, can return a rank that misses the player's last answer. So the answer reply already carries the new total, and the API returns the score it read with each rank. If that score is below the new total, the app shows an updating label and asks again.

Here is what happens when a player opens the rank screen. Notice that the shared top 10 often skips Redis entirely.

A player checks their rank

Everyone shares one cached top 10. Notice that only the personal rank and its neighbours need their own lookups.

Diagram connections:

  • Open the rank screen to Top 10 cached this second?
  • Top 10 cached this second? to Read my rank (Yes)
  • Top 10 cached this second? to Read the top 10 (No)
  • Read the top 10 to Read my rank
  • Read my rank to Read two above, two below
  • Read two above, two below to Show the board
  • Open the rank screen: App → Leaderboard API
  • Top 10 cached this second?: API → API memory
  • Read the top 10: API → Redis replica
  • Read my rank: API → Redis replica
  • Read two above, two below: API → Redis replica
  • Show the board: API → App

How do you explain a leaderboard in a system design interview?

Explain it as two questions, top N and my rank, then show how a sorted set answers both. Grow the design only as the numbers demand.

  1. Ask the shape questions

    How many players, how many updates a second at peak, which boards, and how ties break. Write the numbers on the board.

  2. Show why a table struggles

    The top 10 is easy with an index. One player's rank means counting everyone above them, and that grows with the board.

  3. Bring in the sorted set

    Name the three operations: update a score, read the top N, and read one rank. Each costs about log N.

  4. Settle ties on purpose

    Pick a rule, such as first to reach the score wins, and pack it into the score. Mention that milliseconds still tie at scale, so a database sequence decides, and the 2 to the power 53 limit.

  5. Protect the points

    A database is the source of truth. Events flow through a queue, writes use GT, and corrections start in the database. The board is a view you can rebuild.

  6. Scale last

    Add replicas and a one-second top 10 cache first. Split a board into shards only when writes outgrow one node.

Questions people ask

What data structure is used for a leaderboard?
Most live leaderboards use an ordered structure that also knows positions, such as a skip list or a balanced tree with counts. A large Redis sorted set pairs a skip list with a hash table. The hash table finds a player's score in one lookup, and the skip list keeps everyone in order for ranks and ranges.
Can I build a leaderboard with only SQL?
Yes, for small or slow-moving boards. An index on the points makes the top 10 fast. One player's exact rank still needs a count of everyone above them, which grows with the board. In our PostgreSQL 16 test, a deep rank among 3 lakh rows took about 60 milliseconds.
Why use a heap for top K but not for a full leaderboard?
A min-heap of size K is great for keeping only the best K scores from a stream. It cannot tell an ordinary player their rank, because it forgets everyone outside the top K. A leaderboard must answer rank questions for every player, so it needs a fully ordered structure.
How do I reset a weekly leaderboard?
Do not delete and refill one key. Start writing to a new key named after the new week, and give each weekly key an expiry, such as 35 days. Players can still view last week's final board, and nothing has to be cleared at midnight.
How do I stop players from cheating on a leaderboard?
Let the server decide every point, and never accept a score from the app. Add checks that flag impossible results, such as all 12 answers in under a second. Mark the player as flagged in the database so the worker skips them, then remove them with ZREM until a person reviews them. Without the flag, their next answer adds them straight back.
What happens to the leaderboard if Redis restarts?
If a database is your source of truth, you rebuild the boards from it. A job reads every total for the active boards and loads fresh sorted sets. Redis can also save snapshots or a log of writes to disk, which shortens recovery, but either can miss the latest writes.
Is a leaderboard read-heavy or write-heavy?
It is usually both, at the same peak moments. During a live event, every answer is a write, and every player checking the board is a read. Plan the write rate first, because reads spread over replicas and caches while each board takes all its writes on one node.

Sources and further reading

Practise this with Lala

Practise