DEV Community

Cover image for Your Redis Leaderboard Is Probably Breaking Ties Wrong
Trung Duong
Trung Duong

Posted on

Your Redis Leaderboard Is Probably Breaking Ties Wrong

A leaderboard looks like a one-command problem:

ZADD weekly 100 alice
ZADD weekly 100 bob
ZREVRANGE weekly 0 -1 WITHSCORES
Enter fullscreen mode Exit fullscreen mode

While building
Podium, an open-source Redis-backed
leaderboard service, we discovered that the difficult part begins when two
players have the same score. We are sharing the design because this edge case
can silently turn player IDs into ranking rules.

GitHub logo TeneficGames / podium

High-performance, Redis-backed leaderboards for games and competitive applications.

Podium

CI codecov

Podium high-performance distributed leaderboard

High-performance, Redis-backed leaderboards for games and competitive applications.

Podium provides ready-to-run HTTP and gRPC APIs for scores, ranks, seasons, and player-relative views. It is designed for backend teams operating large fleets of independent leaderboards without provisioning each leaderboard in advance.

  • Fair, deterministic ordering when scores are equal.
  • Single and bulk score updates, including multi-leaderboard fan-out.
  • Standalone Redis and real Redis Cluster integration coverage.
  • Deploy one multi-architecture OCI image with Docker, containerd, Kubernetes or another OCI-compatible runtime.

Quickstart · Performance · API · Documentation · Helm chart · Docker Hub · GHCR

Quickstart

Start Redis 8.2 and the latest stable Podium image:

docker network create podium
docker run --detach --name podium-redis --network podium redis:8.2-alpine

docker run --detach --rm --name podium \
  --network podium \
  --publish 8880:8880 \
  --publish 8881:8881 \
  --env PODIUM_REDIS_HOST=podium-redis \
  --env PODIUM_REDIS_PORT=6379 \
  trungdlp/podium:latest start
Enter fullscreen mode Exit fullscreen mode

Verify the service:

curl http://localhost:8880/healthcheck
Enter fullscreen mode Exit fullscreen mode
WORKING

Submit two equal scores:

curl --request
Enter fullscreen mode Exit fullscreen mode

Both players have 100 points. Alice arrived first, so most game designers would
expect:

1. alice: 100
2. bob:   100
Enter fullscreen mode Exit fullscreen mode

But that is not what the data model says.

Redis sorted sets order members with equal scores lexicographically. With a
reverse range, that secondary ordering is reversed too. Your "fair" tie may
therefore be decided by a player ID.

That is deterministic, but it is not meaningful.

We wanted a rule players could understand:

When scores are equal, the player who reached the current score first ranks
higher.

It sounds simple. Implementing it correctly under concurrent writes was not.

Why the obvious fixes fail

The first idea is usually a timestamp:

tie_break = current_time_in_milliseconds
Enter fullscreen mode Exit fullscreen mode

This creates three problems.

First, two requests can land in the same millisecond. More precision makes that
less likely, but never makes it impossible.

Second, application clocks are not a reliable global ordering mechanism. If
multiple API replicas write to the same Redis instance, clock skew can reorder
arrivals.

Third, the score update and timestamp allocation are separate operations unless
you add a transaction or script. A process can fail between them and leave
partial state.

Another tempting approach is to pack the score and timestamp into one floating
point number:

composite = score * scale + tie_break
Enter fullscreen mode Exit fullscreen mode

Redis sorted-set scores are IEEE 754 doubles. Integers are represented exactly
only up to 2^53 - 1. Packing two independently growing values into that space
quietly reduces the safe range of both. It can work when your limits are small
and explicit, but it is a dangerous default for a general leaderboard service.

We needed an ordering token that was:

  • unique under concurrency;
  • independent of wall clocks;
  • assigned only when a player reaches a new score;
  • committed atomically with every affected index.

Redis itself was the right place to allocate it.

Use a Redis sequence, not time

Podium stores a per-leaderboard sequence initialized to the largest signed
64-bit integer:

9223372036854775807
Enter fullscreen mode Exit fullscreen mode

When a member reaches a new score, an atomic Lua script decrements that value
and formats it as a fixed-width 19-digit prefix.

Conceptually:

redis.call("DECR", sequence_key)
local sequence = redis.call("GET", sequence_key)
local token = left_pad_to_19_digits(sequence) .. public_member_id
Enter fullscreen mode Exit fullscreen mode

The first arrival receives a larger token than the second:

alice -> 9223372036854775806alice
bob   -> 9223372036854775805bob
Enter fullscreen mode Exit fullscreen mode

For a descending leaderboard, Redis's reverse lexicographic ordering now does
exactly what we need. At equal scores, Alice's larger internal token comes
first.

The public ID has not changed. The encoded value is an internal member name,
and a Redis hash maps each public ID to its current token:

public member ID -> current internal token
Enter fullscreen mode Exit fullscreen mode

This mapping is essential when a score changes. We remove the old internal
member, allocate a new sequence, add the replacement, and update the mapping in
one script.

You can see the production implementation in
tiebreak.go.

Idempotency is part of fairness

Suppose Alice reaches 100 first and Bob reaches 100 second:

1. alice: 100
2. bob:   100
Enter fullscreen mode Exit fullscreen mode

Alice's client retries the same request because the network response was lost.
Should she get a new arrival token?

No. A retry must not change rank.

Before allocating a sequence, the Lua script looks up Alice's current internal
token and reads its score. If the submitted score is unchanged, it preserves
the token.

That gives the rule precise semantics:

  • Submitting the same score again keeps the current position.
  • Leaving a score and returning later creates a new arrival.
  • Removing and re-adding a member creates a new arrival.
  • Incrementing by zero internally preserves the arrival token.

These are not edge cases to decide after launch. They are part of the product
contract.

Ascending leaderboards contain a trap

Descending leaderboards are common: more points is better.

But some competitions are ascending: less time, fewer moves, or fewer strokes
is better.

Redis uses normal lexicographic order for ZRANK and reverse lexicographic
order for ZREVRANK. One encoded member cannot put the earlier arrival first in
both directions.

Podium therefore maintains two score indexes:

:scores      -> descending tie order
:scores-asc  -> ascending tie order
Enter fullscreen mode Exit fullscreen mode

The descending token uses the decreasing sequence directly. The ascending
token inverts each sequence digit:

9 - digit
Enter fullscreen mode Exit fullscreen mode

An earlier, larger sequence becomes a lexicographically smaller prefix in the
ascending index. The same player-facing rule now holds in both directions.

This second index costs memory, but it avoids conditional definitions of
"first" that change with leaderboard direction.

Atomicity is the real feature

One score update can touch four pieces of state:

descending sorted set
ascending sorted set
public-ID-to-token hash
sequence counter
Enter fullscreen mode Exit fullscreen mode

Doing that with four client round trips creates four places for races and
partial failure.

Podium runs the operation as one Redis Lua script. Redis executes scripts
atomically, so concurrent arrivals receive distinct sequence values and readers
never observe a half-updated member.

The script also returns the resulting rank. The caller does not need a separate
"write, then read rank" round trip that another request could interleave with.

The concurrency tests launch 128 simultaneous arrivals at the same score and
verify that every member receives a unique rank. Separate tests cover retries,
score changes, removal, re-entry, ascending order, and sequence exhaustion.

The tests are worth reading alongside the implementation:
tiebreak_test.go.

Correctness is not free

The plain sorted-set design is smaller and faster.

In our direct Redis benchmarks, deterministic ordering added approximately:

Operation Latency overhead
Insert and return rank 11%
Change score and return rank 17%
Submit the same score 5%
Read one rank 1%
Read the top 50 3%

Supporting both directions used about 287 Redis bytes per member, compared with
99 bytes for the plain baseline in the measured setup.

Those numbers are not universal capacity promises. They came from local
benchmarks with a specific Redis version, machine, and dataset. They do make
the tradeoff visible: we spend memory and a modest amount of latency to replace
an arbitrary player-ID tie-break with a stable product rule.

For a game where tied players care who arrived first, that is an easy trade to
explain.

The deeper lesson

Redis gives you a total order. That does not mean it gives you the right
order.

Before shipping a leaderboard, write down the answers to these questions:

  1. What happens when scores tie?
  2. Does retrying the same write change position?
  3. What happens after a player leaves and returns to a score?
  4. Can concurrent writes receive the same tie-break value?
  5. Does the rule behave identically for ascending and descending boards?
  6. Are the score and tie-break committed atomically?

If the answer to the first question is "Redis handles it," your member IDs may
already be deciding matches.

Podium exposes this behavior through HTTP and gRPC. I would love to hear how
your system resolves equal scores and which tie rule your players consider
fair. Share your approach or questions in the comments.

Explore Podium on GitHub

Top comments (0)