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:

  1. Increment a Redis counter (cheap).
  2. Periodically (every minute), aggregate to the database.
  3. 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

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 .