A leaderboard looks simple — until a viral event hits and one row gets 100k updates per second. This post is the working set of patterns for live counters and leaderboards at scale.
Redis sorted set — the foundation
ZADD leaderboard 1500 user-42
ZADD leaderboard 1820 user-77
ZINCRBY leaderboard 50 user-42
ZREVRANGE leaderboard 0 9 WITHSCORES # top 10
ZSCORE leaderboard user-42 # user's score
ZREVRANK leaderboard user-42 # user's rank
O(log N) for inserts and rank queries. Handles tens of millions of entries on a single Redis instance. For most products, this is enough.
When it stops scaling
- 100k+ updates/sec on the same sorted set.
- A single hot key concentrates load on one Redis shard.
- One-shot recomputation slow.
Counter sharding
For hot counters (likes on a viral post, votes on a viral comment):
counter:post:42 ──split──> counter:post:42:s0
counter:post:42:s1
...
counter:post:42:s7
Each write picks a random shard:
shard = random.randrange(8)
redis.incr(f"counter:post:42:s{shard}")
# Read aggregates
total = sum(int(redis.get(f"counter:post:42:s{i}") or 0) for i in range(8))
8 shards = 8× write throughput per key. Reads are slightly more expensive (8 GETs) but cacheable.
Eventual consistency
For displayed counts, real-time isn’t required. Cache the aggregate:
async def get_count(post_id: int) -> int:
cached = await redis.get(f"count:cache:{post_id}")
if cached:
return int(cached)
total = sum(...)
await redis.set(f"count:cache:{post_id}", total, ex=2) # 2s TTL
return total
A 2-second-old count is fine for “1.2k likes”. Massive load reduction.
Async aggregation
For huge counters (Twitter’s tweet view counter), don’t INCR at all:
- Increment a Redis counter (cheap).
- Periodically (every minute), aggregate to the database.
- Display reads from the cache; eventual consistency.
For the broader patterns see Design Twitter / News Feed .
Time-windowed leaderboards
“Top players this week”:
weekly:2026-W18 → Redis sorted set per week
A new ZSET each week; old ones drop after retention. Daily / hourly leaderboards work the same way.
Persistence
Sorted sets in Redis survive restart with AOF/RDB. For long-term: snapshot to Postgres periodically.
INSERT INTO leaderboard_snapshots (date, leaderboard_data)
SELECT current_date, ...
Postgres has the durable record; Redis serves the live queries.
Common mistakes
1. Sorting in the database
ORDER BY score DESC LIMIT 10 on a 10M-row table → seq scan or expensive index sort. Redis sorted sets do this in O(log N).
2. Reading top-100 to find rank #50
Use ZSCORE + ZREVRANK directly. Not the full top list.
3. No caching of aggregates
Every page-load recomputing the top-10 on a hot leaderboard. Cache 1–10s.
4. Single counter for everything
A “global activity counter” hits one Redis key. Shard.
5. No fallback when Redis dies
The product breaks. Have a fallback (DB-backed count, eventually consistent) or graceful degrade.
What I’d build today
For a small product:
- Postgres for durable user / score data.
- Redis sorted set for live leaderboard.
- Redis counters with TTL’d cache for view counts.
- Per-tenant leaderboards in multi-tenant apps.
Scales surprisingly far on cheap infra.
Read this next
- Caching Strategies in 2026
- Design Twitter / News Feed
- Design a Distributed Rate Limiter at Scale
- Distributed Systems Fundamentals
If you want a sharded counter library + leaderboard reference, it’s at rajpoot.dev .
Building something AI-, backend-, or data-heavy and want a second pair of eyes? I do consulting and freelance work — see my projects and ways to reach me at rajpoot.dev .