Real-time Ranking at Scale
Our MySQL database hit a wall the moment we reached one million users. We were building a competitive gaming feature where players expected to see their global rank change the second they earned points. While relational databases are great for many things, running ORDER BY score DESC on a million-row table every time a user checks their profile is a recipe for disaster.
The latency was noticeable. API response times spiked to over 2,000ms, and the database CPU stayed pinned at 90%. We needed a solution that could handle thousands of concurrent score updates per second while providing instantaneous rank lookups. That led us to Redis Sorted Sets (ZSETs). After six months in production, our ranking engine now handles peak loads without breaking a sweat. Here is how we built it.
Quick Start: The 5-Minute Leaderboard
Redis Sorted Sets are built for this exact purpose. Every element in a Sorted Set is paired with a score. Redis uses these scores to keep everything in order automatically. Unlike a standard list, you can find a specific user’s rank or fetch a range of top players in logarithmic time.
Basic Commands
You only need four core commands to manage a high-performance leaderboard:
# Add a user or update their score
ZADD game_leaderboard 1500 "user_88"
# Increment a score (perfect for real-time point gains)
ZINCRBY game_leaderboard 50 "user_88"
# Get the top 10 players (highest to lowest)
ZREVRANGE game_leaderboard 0 9 WITHSCORES
# Find the exact rank of a specific user
ZREVRANK game_leaderboard "user_88"
Implementation in Python
Using the redis-py library, you can wrap these commands into a simple service. This approach keeps your application logic clean and your database queries fast.
import redis
r = redis.Redis(host='localhost', port=6379, db=0)
def update_score(user_id, points):
# ZINCRBY adds points to the existing score or creates it
r.zincrby("global_leaderboard", points, user_id)
def get_top_players(n=10):
# Returns a list of (user_id, score) tuples
return r.zrevrange("global_leaderboard", 0, n-1, withscores=True)
def get_user_rank(user_id):
# ZREVRANK is 0-indexed, so we add 1 for a human-readable rank
rank = r.zrevrank("global_leaderboard", user_id)
return rank + 1 if rank is not None else None
# Usage
update_score("player_alpha", 100)
print(f"Top 10: {get_top_players()}")
print(f"Your Rank: {get_user_rank('player_alpha')}")
The Architecture: Why it Scales
Under the hood, Redis combines a Skip List with a Hash Table. This dual-structure approach ensures that most operations, including adding a user or fetching a rank, maintain O(log(N)) complexity. In a leaderboard with 10 million users, finding a rank takes only about 24 comparisons.
Standard SQL databases struggle because they often need to scan large indexes or handle heavy disk I/O to maintain order. Redis keeps the entire structure in memory. When you update a score, the Skip List allows Redis to re-position the user almost instantly without a full table rebuild.
At our peak, we handle 5,000 score updates every second. Our Redis CPU usage rarely climbs above 15%. Compared to our old PostgreSQL setup, which caused 2-second API delays, the sub-millisecond response time from Redis feels like magic to our users.
Advanced Usage: Solving Real-World Problems
1. Handling Ties Fairly
Redis defaults to alphabetical sorting for users with identical scores. In a competitive environment, the player who reached the score first usually deserves the higher rank. We solve this by creating a “composite score.”
By appending a timestamp fraction to the base score, you can break ties chronologically. Since we want the earlier timestamp to win, we subtract the current time from a future date constant.
import time
# A future constant (e.g., year 2050)
MAX_TIMESTAMP = 2524608000
def update_score_with_tie_break(user_id, base_score):
# The earlier the timestamp, the larger the decimal fraction
time_fraction = (MAX_TIMESTAMP - int(time.time())) / MAX_TIMESTAMP
final_score = base_score + time_fraction
r.zadd("leaderboard_with_ties", {user_id: final_score})
2. Daily and Weekly Windows
Players engage more when they have a fresh chance to win. We manage time-based leaderboards by using dynamic keys like leaderboard:2023-10-27. We also set an expiration (TTL) on these keys. This ensures that old daily data clears out automatically, keeping our memory costs low.
3. The “Social” View: Around Me
Global ranks are great, but seeing the three players immediately above and below you is often more motivating. You can implement this by fetching the user’s rank and calculating a small range around it.
def get_around_me(user_id, radius=3):
rank = r.zrevrank("global_leaderboard", user_id)
if rank is None: return []
# Fetch players within the specified radius
start = max(0, rank - radius)
end = rank + radius
return r.zrevrange("global_leaderboard", start, end, withscores=True)
Lessons from 6 Months in Production
Memory Management
RAM is your most expensive resource in Redis. For a leaderboard with 10 million users using UUIDs for keys, expect to use approximately 1.2GB to 1.5GB of memory. We recommend setting a maxmemory policy. We use volatile-lru to prevent crashes, though we monitor closely to scale vertically before we ever hit that limit.
Persistence Strategy
Leaderboard data needs to survive a reboot. We use AOF (Append Only File) with the everysec policy. This balances performance with safety. In the event of a crash, we might lose one second of score updates, which is a fair trade-off for the massive speed gains we get during normal operation.
Atomic Logic with Lua
Sometimes you only want to update a score if the new one is better than the old one. Sending multiple commands back and forth creates network lag. A small Lua script allows you to run this logic directly on the Redis server in a single atomic step.
-- Only update if the new score (ARGV[1]) is higher than current
local current_score = redis.call('ZSCORE', KEYS[1], ARGV[2])
if not current_score or tonumber(ARGV[1]) > tonumber(current_score) then
return redis.call('ZADD', KEYS[1], ARGV[1], ARGV[2])
end
return 0
The Final Verdict
We still use PostgreSQL for user profiles and payment history, but moving the ranking logic to Redis was a game-changer. It decoupled high-frequency writes from our primary database and slashed our latency. If your application is struggling to sort millions of rows in real-time, Redis Sorted Sets are the most effective tool for the job.

