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.
Written by Adarsh Gupta
Full Stack Developer building admin panels · backend apis from Jaipur, India.