A counter looks trivial. Then a viral post hits, 100k increments per second land on one row, and the database falls over. This post is the design at every scale.
Phase 1: Postgres single-row
UPDATE posts SET view_count = view_count + 1 WHERE id = $1;
Works up to ~10k updates/sec on the same row. Beyond that: row-level lock contention kills throughput.
For 99% of products: this is enough.
Phase 2: Redis single-key
await redis.incr(f"views:post:{post_id}")
Atomic. Fast. ~100k ops/sec on one Redis instance.
Persist to Postgres periodically:
async def flush_views():
keys = await redis.keys("views:post:*")
for k in keys:
post_id = int(k.split(":")[-1])
delta = await redis.getdel(k) # atomic get + delete
if delta:
await db.execute(
"UPDATE posts SET view_count = view_count + $1 WHERE id = $2",
int(delta), post_id,
)
Run every minute. Redis takes the load; Postgres has the durable counter.
Phase 3: Sharded counter
For very hot keys (>100k/sec):
SHARDS = 8
async def increment(post_id: int):
shard = random.randrange(SHARDS)
await redis.incr(f"views:post:{post_id}:s{shard}")
async def read(post_id: int) -> int:
pipeline = redis.pipeline()
for s in range(SHARDS):
pipeline.get(f"views:post:{post_id}:s{s}")
values = await pipeline.execute()
return sum(int(v or 0) for v in values)
8 keys per logical counter. Random shard per increment → 8× write throughput. Read sums all 8.
Pair with caching strategies so reads cache for ~5 seconds.
Phase 4: Async aggregation
For massive scale (>1M ops/sec):
Increment events → Kafka → consumers
↓
Per-window aggregate
↓
Postgres / ClickHouse
Don’t increment in-line. Emit an event. A consumer aggregates per minute / hour. The view count is “eventually consistent” — fine for view counts.
For Kafka patterns .
Reading
- Real-time-ish: read from Redis (sharded sum).
- Cached: 5s TTL on the sum.
- Long-tail: read from Postgres or ClickHouse periodically aggregated.
Most “live counter” UIs tolerate 5–30s delay; cache aggressively.
Idempotency
Counters incremented from clients can double-count if requests retry:
# Naive: same view counted twice
await redis.incr(f"views:post:{post_id}")
# Better: dedupe by event id
key = f"viewed:{user_id}:{post_id}:{day}"
if await redis.set(key, "1", nx=True, ex=86400):
await redis.incr(f"views:post:{post_id}")
Dedup window of “user viewed post X today” prevents farmers from inflating counters. See Idempotency .
Decay / windowed counters
For “trending” counts: weight recent more than old.
trend_score(post) = views_last_hour + 0.5*views_last_day + 0.1*views_last_week
Compute via async windows. ClickHouse handles this elegantly.
Common mistakes
1. Single-row Postgres at hot-key scale
Lock contention. Fall over. Move to Redis.
2. Reading raw counter on every page load
100k req/sec hitting Redis × 8 shard reads = 800k ops/sec. Cache the sum.
3. Synchronous flush to Postgres
Every increment writes through. Postgres is the bottleneck again. Async batch.
4. No idempotency
Bots farm; counters inflate. Dedup with sensible windows.
5. No max bound
Buggy client increments 1000× per page. Counter explodes. Rate-limit increments per (user, key, time).
What I’d ship today
- Up to 10k req/sec on a single counter: Postgres direct.
- 10k–100k: Redis with batched flush.
- 100k–1M: Redis sharded counters.
- >1M: Kafka + windowed aggregation.
Match the architecture to the actual load.
Read this next
- Design a Leaderboard / Counter System at Scale
- Caching Strategies in 2026
- Idempotency, Retries, and Exactly-Once Illusions
- Distributed Systems Fundamentals
If you want a Python sharded-counter library + flush worker, 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 .