Leaderboards are deceptively simple to describe and surprisingly hard to scale. "Show me the top 100 users, ranked by points" sounds like a basic database query. At scale, it becomes a bottleneck that can bring down your server.
When we built the leaderboard for SafaiPay, we had two options. We chose the right one.
The Wrong Way: SQL ORDER BY
The obvious approach: store user points in PostgreSQL, query with ORDER BY points DESC LIMIT 100.
This works fine with 100 users. With 100,000 users, it requires sorting the entire table on every request — O(N log N) every time someone opens the leaderboard screen. Add database indexes and it improves, but you're still doing significant work per request.
More critically: if points update frequently — every time a user completes an action — you need the leaderboard to reflect those updates in real time. With SQL, you're either running expensive queries constantly or showing stale data.
The Right Way: Redis Sorted Sets
Redis is an in-memory data store. Everything lives in RAM, which makes it orders of magnitude faster than disk-based databases for the right operations.
A Redis Sorted Set is a data structure that maintains a collection of members, each associated with a floating-point score, always sorted by score. This is exactly what a leaderboard needs.
Three operations cover the entire leaderboard feature:
ZADD leaderboard 150 "user:123"— add or update a user's scoreZREVRANGE leaderboard 0 99 WITHSCORES— fetch top 100 users with scoresZREVRANK leaderboard "user:123"— get a specific user's rank
Every one of these is O(log N) — the cost grows logarithmically with the number of users, not linearly. With 1 million users, fetching the top 100 is barely slower than with 1,000 users.
How We Wired It Into SafaiPay
Every time a user earned points — reporting garbage, completing a pickup, hitting a badge milestone — two things happened simultaneously:
- PostgreSQL recorded the transaction (permanent ledger)
- Redis updated the leaderboard score with
ZINCRBY
ZINCRBY leaderboard 10 "user:456" — increment user 456's score by 10. Atomic, instant, no race conditions.
When a user opened the leaderboard screen, we fetched the top 50 users from Redis in a single command — typically under 1 millisecond. We also fetched the requesting user's personal rank with ZREVRANK, so they always saw where they stood relative to the top.
Handling Score Ties
When two users have the same score, Redis orders them lexicographically by member name — essentially arbitrary. For a civic app, this is fine. For a competitive game, you might want to break ties by timestamp: encode the time of the last point event into the score as a decimal fraction, so earlier achievers rank higher among equals.
Persistence: Redis Is Not a Database
Redis is fast because it lives in memory. Memory is volatile — if the server restarts, data is lost unless you've configured persistence.
We solved this simply: Redis is our leaderboard cache, not our source of truth. PostgreSQL holds the permanent point balances. On server restart, we rebuild the Redis leaderboard from PostgreSQL with a single query — typically takes under a second for our user count.
Use Redis for speed. Use PostgreSQL for truth. Never confuse the two.
This pattern — fast read layer in Redis, durable write layer in PostgreSQL — appears in almost every high-scale backend. Learn it once and you'll reach for it constantly.