Skip to content
esc
↑ ↓ navigate↵ select
All posts

Real-Time Leaderboards With Redis Sorted Sets

3 min read

  • Redis
  • WebSockets
  • FastAPI
  • Backend
On this page

A sports prediction app lives and dies by its leaderboard. Scores change the moment a match ends, and thousands of people refresh to see where they landed. On the platform I built the backend for, rankings come from Redis sorted sets, not from the database.

Why Not Just ORDER BY score DESC?

For a page of the top 10, SQL is fine. The expensive question is the one every user asks: "what's my rank?"

SELECT COUNT(*) + 1 FROM scores WHERE points > :my_points;

That's a scan over everyone ahead of you, on every request, while scores are being written. It gets slower exactly when traffic peaks.

What a Sorted Set Gives You

A sorted set stores unique members, each with a numeric score, kept in order. The operations that matter are all O(log n):

Command What it answers
ZADD / ZINCRBY Set or add to a user's points
ZREVRANK Where is this user? (0 = first)
ZREVRANGE ... WITHSCORES Top N with points
ZSCORE This user's points

Rank lookups stay fast however many players there are.

One Set per Ranking

Global and division ladders are just different keys:

# Simplified sketch with redis-py
def award_points(r: Redis, user_id: str, division: int, points: int):
    pipe = r.pipeline()
    pipe.zincrby("lb:global", points, user_id)
    pipe.zincrby(f"lb:division:{division}", points, user_id)
    pipe.execute()

def my_standing(r: Redis, user_id: str, division: int):
    pipe = r.pipeline()
    pipe.zrevrank("lb:global", user_id)
    pipe.zrevrank(f"lb:division:{division}", user_id)
    pipe.zscore("lb:global", user_id)
    global_rank, div_rank, points = pipe.execute()
    return {
        "global": None if global_rank is None else global_rank + 1,
        "division": None if div_rank is None else div_rank + 1,
        "points": points or 0,
    }

Pipelining sends all of these in one round trip.

The Database Is Still the Source of Truth

Redis is the fast read model. The scoring engine runs on the server and writes results to PostgreSQL first. Clients never submit their own points. Then it updates the sorted sets. If Redis is ever flushed, a rebuild job replays scores from Postgres back into the sets.

That split keeps the rules auditable and the reads cheap.

Pushing Changes Instead of Polling

When a match settles, the server recomputes the affected ranks and broadcasts over WebSockets, so nobody has to hammer refresh:

async def on_match_settled(match_id: int):
    changed = await apply_scores(match_id)  # writes Postgres, then Redis
    top = r.zrevrange("lb:global", 0, 9, withscores=True)
    await manager.broadcast({"type": "leaderboard", "top": top})
    for user_id in changed:
        await manager.send_to(user_id, {"type": "standing", **my_standing(r, user_id, division_of(user_id))})

Everyone gets the top 10. Each affected user also gets their own new rank.

Ties

Sorted sets break ties by member name, which is arbitrary for users. If "first to reach the score wins" matters, fold a timestamp into the score. For example, points * 1e10 + (MAX_TS - reached_at) keeps it a single number that sorts correctly.

The Takeaway

When the hot question is "where do I rank?", keep rankings in a structure built to answer it. Sorted sets make rank lookups O(log n), Postgres keeps the truth, and WebSockets deliver the change the moment it happens.

Found this useful? Pass it on.

AK

Written by Adarsh Gupta

Full Stack Developer building admin panels · backend apis from Jaipur, India.

Say hi