How to Design a Distributed Counter System
Building like buttons, view counters, and real-time aggregation for Facebook-scale engagement metrics at billion-QPS scale
1. Introduction — Why Distributed Counters Are Hard
At first glance, a counter seems like the simplest data structure in computer science. A variable that starts at zero, increments on each event, and returns a number. What could possibly go wrong? As it turns out, almost everything — once you move past a single machine and into the realm of distributed systems.
Consider Facebook's like button. Every second, Facebook processes over 1 million likes. Each like triggers an increment on a counter that must be read by millions of other users viewing that same post within milliseconds. YouTube's view counter must handle billions of daily views across millions of videos while remaining eventually consistent and never going backwards. Twitter's retweet counter must aggregate retweets from clients distributed across every continent, filtering out bots, spam, and duplicate events — all while maintaining sub-second freshness.
The fundamental challenge of a distributed counter is deceptively simple: how do you make millions of writes per second visible to millions of readers per second, while keeping the count accurate enough to be useful, fast enough to feel real-time, and cheap enough to be economically viable?
Let's break down why this problem is genuinely difficult:
Five Reasons Distributed Counters Are Non-Trivial
- Write amplification: A single viral post generating 100,000 likes per minute cannot be served by a single database row without creating a catastrophic hot partition. The single-row counter is a thundering herd magnet.
- Read-after-write consistency: Users who just liked a post expect to see the updated count immediately. But if you shard the counter, reads must fan out to multiple shards, adding latency.
- Exactly-once semantics: Network retries, client bugs, and duplicate event delivery mean the same like may arrive twice. Without deduplication, counters inflate beyond reality.
- Failure tolerance: If a counter shard goes down, you cannot simply stop counting likes. The system must degrade gracefully — perhaps returning a slightly stale count rather than an error.
- Cross-region consistency: Users in Tokyo and New York viewing the same YouTube video may see different counts for several seconds. Managing this divergence while keeping both users happy is a balancing act.
The distributed counter problem is a canonical system design interview question because it touches nearly every critical concern in distributed systems: partitioning, replication, consistency, availability, fault tolerance, caching, and real-time streaming. It is not merely an academic exercise — the patterns used to solve it are the same patterns behind Facebook's TAO, YouTube's counters, Twitter's metrics infrastructure, and Netflix's view tracking pipeline.
In this guide, we will design a distributed counter system from the ground up — starting from requirements, moving through capacity estimation, architecture, sharding, persistence, approximate counting, exactly-once semantics, and finishing with a production-grade C# implementation. Whether you are preparing for a senior staff-level system design interview or building a real counter service for your product, this article will give you a thorough understanding of every trade-off involved.
"A counter is the simplest example of a distributed agreement problem. Every system that counts events eventually confronts the same question: how much accuracy are you willing to trade for how much throughput?"
2. Functional & Non-Functional Requirements
Functional Requirements
| Operation | Description | Priority |
|---|---|---|
increment(counter_id) | Increase counter value by 1 (or by a specified delta) | P0 |
decrement(counter_id) | Decrease counter value by 1 (or by a specified delta) | P0 |
get_count(counter_id) | Return the current (or approximate) count | P0 |
get_batch_counts(counter_ids[]) | Return counts for multiple counters in a single call | P1 |
get_count_window(counter_id, window) | Return count within a time window (e.g., last 5 minutes) | P1 |
reset(counter_id) | Reset counter to zero (admin only) | P2 |
Non-Functional Requirements
| Requirement | Target | Rationale |
|---|---|---|
| Availability | 99.99% | Counters are read-heavy; stale data is better than errors |
| Latency (write) | P99 < 10ms | Increment must be fast to not block user actions |
| Latency (read) | P99 < 50ms | Reads can tolerate slightly higher latency via caching |
| Consistency | Eventually consistent | Strong consistency is too expensive at scale; 1-5 second lag is acceptable |
| Durability | At-least-once with dedup | Losing increments is worse than double-counting (with dedup) |
| Throughput | 1M+ increments/sec globally | Facebook-scale like button traffic |
| Accuracy | Within 0.01% for exact; within 1% for approximate | Different use cases need different accuracy levels |
| Retention | 30 days raw, 1 year aggregated | Analytics and time-windowed counters need historical data |
Consistency Trade-Off Decision
For most real-world counter systems, eventual consistency is the correct choice. A user seeing 4,823 likes instead of 4,824 for a fraction of a second is imperceptible. But the cost of strong consistency — synchronous cross-shard coordination, distributed locks, or linearizable reads — would reduce throughput by 10-100x and introduce single points of failure. The key insight: counters are forgiving. Views counts and like counts do not need to be exactly correct; they need to be approximately correct, very fast.
3. Capacity Estimation
Before designing any system, we must estimate the scale. Let's work backwards from a Facebook-scale scenario.
Write Throughput
| Metric | Value | Calculation |
|---|---|---|
| Total daily active users | 2 billion | Assumption based on Facebook-scale |
| Avg likes per user per day | 10 | Conservative estimate |
| Total daily likes | 20 billion | 2B × 10 |
| Peak likes per second | 1M+ | 20B / 86400 × 5 (peak factor) |
| Unique counters active at any time | 100 million | Active posts, videos, pages |
Read Throughput
| Metric | Value | Calculation |
|---|---|---|
| Feed views per second | 10M | 2B users × 10 page views/day / 86400 |
| Avg counters per feed view | 5 | Likes, comments, shares, reactions per post |
| Total counter reads/sec | 50M | 10M × 5 |
| With 90% cache hit rate | 5M | 50M × 0.1 (cache misses) |
Storage Estimation
| Component | Per Record | Total Records | Total Storage |
|---|---|---|---|
| Counter shards (hot) | 64 bytes | 100M × 10 shards = 1B | ~64 GB (fits in RAM) |
| Counter shards (cold) | 64 bytes | 500M × 10 shards = 5B | ~320 GB |
| WAL (write-ahead log) | 128 bytes | 1M writes/sec × 3600 × 24 × 7 | ~7.4 TB (7-day retention) |
| Time-windowed buckets | 32 bytes | 100M × 360 (1-min buckets/day) | ~1.1 TB |
Key Takeaway
The hot working set (~64 GB) fits comfortably in Redis Cluster across 16 nodes with 4 GB each. The cold storage and WAL can live on compressed SSDs. The ratio of write-heavy hot data to read-heavy cold data drives the multi-tier caching architecture we will design later.
4. Data Model
Core Entities
erDiagram
COUNTER ||--o{ COUNTER_SHARD : "partitioned into"
COUNTER_SHARD ||--o{ SHARD_AGGREGATE : "aggregated into"
COUNTER ||--o{ TIME_WINDOW : "bucketed by"
COUNTER ||--o{ INCREMENT_EVENT : "receives"
INCREMENT_EVENT ||--|| IDEMPOTENCY_KEY : "deduped by"
COUNTER {
string counter_id PK
string entity_type
string entity_id
string counter_type
bigint estimated_count
timestamp updated_at
}
COUNTER_SHARD {
string shard_id PK
string counter_id FK
int shard_index
bigint count
timestamp updated_at
}
SHARD_AGGREGATE {
string aggregate_id PK
string counter_id FK
bigint total_count
timestamp computed_at
}
TIME_WINDOW {
string window_id PK
string counter_id FK
string window_size
timestamp window_start
bigint count
}
INCREMENT_EVENT {
string event_id PK
string counter_id FK
string idempotency_key
int delta
timestamp created_at
string source_region
}
IDEMPOTENCY_KEY {
string key PK
string event_id FK
timestamp expires_at
}
Shard Count Explanation
Each logical counter (e.g., "likes on post #12345") is split into N shards (typically 10-100). When an increment arrives, a hash of the counter ID plus a random shard selector routes the write to exactly one shard. The total count is the sum of all shard values. This eliminates the hot-row problem: instead of one row receiving 1M writes/sec, 10 shards each receive 100K writes/sec.
Why 10-100 Shards?
With 10 shards, a single hot counter's writes are distributed across 10 rows, reducing contention by 10x. With 100 shards, you get 100x distribution but 100x more reads for aggregation. The sweet spot depends on the write-to-read ratio. For like buttons (high write, moderate read), 10-20 shards is typical. For analytics counters (batch reads), 50-100 shards are acceptable.
5. API Design
REST API Endpoints
POST /api/v1/counters/{counter_id}/increment
POST /api/v1/counters/{counter_id}/decrement
GET /api/v1/counters/{counter_id}/count
POST /api/v1/counters/batch
GET /api/v1/counters/{counter_id}/count?window=5m
DELETE /api/v1/counters/{counter_id} (admin)
Increment Request/Response
// POST /api/v1/counters/post:12345:likes/increment
{
"delta": 1,
"idempotency_key": "evt-abc-123-def",
"user_id": "user_98765",
"timestamp": "2026-07-14T10:30:00Z"
}
// Response 202 Accepted
{
"counter_id": "post:12345:likes",
"acknowledged": true,
"estimated_count": 4824,
"shard_target": 3,
"server_timestamp": "2026-07-14T10:30:00.123Z"
}
Why 202 instead of 200?
The increment endpoint returns 202 Accepted, not 200 OK. This signals that the increment has been accepted for processing but not yet fully committed. The estimated_count is a best-effort snapshot, not a guaranteed accurate value. This is a crucial distinction for eventual consistency.
Get Count Response
// GET /api/v1/counters/post:12345:likes/count
{
"counter_id": "post:12345:likes",
"count": 4824,
"accuracy": "exact",
"shard_count": 10,
"aggregated_at": "2026-07-14T10:30:01.000Z",
"latency_ms": 12
}
Batch Count Request
// POST /api/v1/counters/batch
{
"counter_ids": [
"post:12345:likes",
"post:12345:comments",
"post:12345:shares",
"user:98765:follower_count"
]
}
// Response 200 OK
{
"counts": {
"post:12345:likes": 4824,
"post:12345:comments": 312,
"post:12345:shares": 87,
"user:98765:follower_count": 15420
}
}
6. High-Level Architecture
flowchart TB
subgraph Clients["Client Layer"]
MobileApp["Mobile App"]
WebApp["Web App"]
APIConsumer["API Consumer"]
end
subgraph Gateway["API Gateway Layer"]
LB["Load Balancer"]
RateLimit["Rate Limiter"]
Auth["Auth Middleware"]
end
subgraph CounterService["Counter Service Cluster"]
IncrAPI["Increment API"]
ReadAPI["Read API"]
BatchAPI["Batch API"]
Dedup["Deduplication Engine"]
ShardRouter["Shard Router"]
end
subgraph HotTier["Hot Tier (In-Process + Redis)"]
L1["L1: In-Process Cache"]
L2["L2: Redis Cluster"]
end
subgraph ColdTier["Cold Tier (Database + WAL)"]
WAL["Write-Ahead Log (Kafka)"]
DB["Database Cluster"]
Aggregator["Stream Aggregator"]
end
subgraph Analytics["Analytics Layer"]
HLL["HyperLogLog Service"]
CMS["Count-Min Sketch"]
TimeWindow["Time Window Buckets"]
end
MobileApp & WebApp & APIConsumer --> LB
LB --> RateLimit --> Auth
Auth --> IncrAPI & ReadAPI & BatchAPI
IncrAPI --> Dedup --> ShardRouter
ShardRouter --> L1 --> L2
ReadAPI --> L1
BatchAPI --> L2
L2 --> WAL
WAL --> DB
DB --> Aggregator
Aggregator --> L2
IncrAPI --> HLL & CMS & TimeWindow
ReadAPI --> TimeWindow
Component Responsibilities
| Component | Responsibility | Technology |
|---|---|---|
| API Gateway | Authentication, rate limiting, routing | Kong / Envoy |
| Increment API | Validates and routes increment requests | .NET 8 / ASP.NET Core |
| Read API | Serves count reads with caching | .NET 8 / ASP.NET Core |
| Deduplication Engine | Filters duplicate increments via idempotency keys | Redis SET + Bloom Filter |
| Shard Router | Determines target shard via consistent hashing | Custom hash ring |
| L1 In-Process Cache | Sub-millisecond reads for hot counters | MemoryCache / ConcurrentDictionary |
| L2 Redis Cluster | Distributed counter storage for hot data | Redis Cluster (16+ nodes) |
| Write-Ahead Log | Durable persistence buffer for counter increments | Apache Kafka |
| Database Cluster | Durable counter storage for all shards | Cassandra / DynamoDB |
| Stream Aggregator | Periodically sums shards into aggregated counts | Apache Flink |
| HyperLogLog Service | Approximate unique user counts | Redis HLL + custom engine |
7. Counter Sharding Strategy
Sharding is the single most important technique in distributed counter design. Without sharding, a viral post's counter becomes a hot partition that bottlenecks the entire system. With sharding, writes are parallelized across multiple storage locations.
flowchart LR
subgraph Incoming["Incoming Increments"]
I1["incr(post:123:likes)"]
I2["incr(post:123:likes)"]
I3["incr(post:123:likes)"]
I4["incr(post:123:likes)"]
end
subgraph Router["Shard Router"]
H["hash(counter_id + random(0..N-1))"]
end
subgraph Shards["Counter Shards"]
S0["Shard 0: count=12"]
S1["Shard 1: count=8"]
S2["Shard 2: count=15"]
S3["Shard 3: count=11"]
SN["..."]
end
subgraph Agg["Aggregation"]
Sum["Total = sum(all shards) = 46+"]
end
I1 & I2 & I3 & I4 --> H
H --> S0 & S1 & S2 & S3 & SN
S0 & S1 & S2 & S3 & SN --> Sum
Hash-Based Sharding
The shard index is computed as:
shard_index = hash(counter_id + ":" + random(0, num_shards - 1)) % num_shards
We include a random component in the hash input so that concurrent increments to the same counter are naturally distributed across different shards. This is critical for avoiding hot shards. The random() call ensures that even if two users simultaneously like the same post, they will likely write to different shards.
Counter-per-Shard Aggregation
When a read arrives, the system must sum all shard values. This is where the trade-off becomes apparent:
Shard Count Trade-Off
More shards = better write distribution, higher write throughput, but slower reads (must sum more values). Fewer shards = faster reads, but more contention on hot counters. The optimization is to maintain a cached aggregate that is updated asynchronously — reads hit the cached aggregate (O(1)), and the aggregate is recomputed every few seconds from the underlying shards.
8. In-Memory Counter with Async Persistence
The highest-throughput counter pattern is the write-behind approach: increments happen entirely in memory, and persistence is handled asynchronously. This is how Facebook's TAO and many production systems work.
flowchart TB
subgraph AppServer["Application Server"]
Mem["In-Memory Counter Store"]
Queue["Write-Behind Queue"]
Timer["Flush Timer (every 5s)"]
end
subgraph Persistence["Async Persistence"]
Kafka["Kafka Producer"]
KafkaTopic["kafka: counter-events"]
Consumer["Kafka Consumer"]
Redis["Redis Cluster"]
DB["Cassandra"]
end
Mem -->|"increment() instant"| Mem
Mem -->|"batch flush every 5s"| Queue
Queue --> Kafka
Timer -->|"trigger flush"| Queue
Kafka --> KafkaTopic
KafkaTopic --> Consumer
Consumer --> Redis
Consumer --> DB
Write-Behind with Coalescing
The key optimization is coalescing: instead of persisting every individual increment, the write-behind queue batches increments by counter ID and merges them into a single aggregate delta. For a counter receiving 10,000 increments per second, this reduces persistence writes from 10,000/sec to perhaps 1/sec per counter (one aggregated delta).
// Pseudo-code for coalescing write-behind queue
public class CoalescingWriteBehindQueue
{
private readonly ConcurrentDictionary<string, long> _pendingDeltas = new();
private readonly Timer _flushTimer;
private readonly IKafkaProducer _kafkaProducer;
public CoalescingWriteBehindQueue()
{
_flushTimer = new Timer(FlushAsync, null,
TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(5));
}
public void EnqueueIncrement(string counterId, long delta)
{
// Atomic add — no lock needed
_pendingDeltas.AddOrUpdate(counterId, delta,
(key, existing) => existing + delta);
}
private async void FlushAsync(object? state)
{
// Snapshot and reset all pending deltas atomically
var snapshot = new ConcurrentDictionary<string, long>(
Interlocked.Exchange(ref _pendingDeltas,
new ConcurrentDictionary<string, long>()));
foreach (var (counterId, totalDelta) in snapshot)
{
var event = new CounterEvent
{
CounterId = counterId,
Delta = totalDelta,
Timestamp = DateTime.UtcNow
};
await _kafkaProducer.ProduceAsync("counter-events", event);
}
}
}
Coalescing Impact
For a counter receiving 10,000 increments/second with 5-second flush intervals, without coalescing we would write 50,000 records per flush. With coalescing, we write exactly 1 record per counter per flush — a 50,000x reduction in write amplification. The trade-off is that increments are only durable after the next flush (up to 5 seconds), which is acceptable for like buttons.
9. Redis Counter Patterns
Redis is the de facto standard for distributed counters due to its atomic operations, single-threaded event loop, and in-memory speed. Let's examine the key patterns.
Pattern 1: Simple INCR
// Basic Redis counter — atomic increment
// SET post:12345:likes:shard0 0
// INCR post:12345:likes:shard0
// C# implementation using StackExchange.Redis
public class RedisShardedCounter
{
private readonly IConnectionMultiplexer _redis;
private readonly int _numShards;
public RedisShardedCounter(IConnectionMultiplexer redis, int numShards)
{
_redis = redis;
_numShards = numShards;
}
public async Task IncrementAsync(string counterId, long delta = 1)
{
var db = _redis.GetDatabase();
var shard = GetRandomShard(counterId);
var key = $"{counterId}:shard:{shard}";
await db.StringIncrementAsync(key, delta);
}
public async Task<long> GetCountAsync(string counterId)
{
var db = _redis.GetDatabase();
var keys = Enumerable.Range(0, _numShards)
.Select(i => (RedisKey)$"{counterId}:shard:{i}")
.ToArray();
var values = await db.StringGetAsync(keys);
return values.Sum(v => v.HasValue ? (long)v : 0);
}
private int GetRandomShard(string counterId)
{
var hash = HashCode.Combine(counterId, Random.Shared.Next(_numShards));
return Math.Abs(hash) % _numShards;
}
}
Pattern 2: HyperLogLog for Unique Counts
Counting unique users who liked a post is fundamentally different from counting total likes. If User A unlikes and re-likes, the total count is 2 but the unique count is 1. HyperLogLog solves this with ~1% error using only 12 KB of memory.
// HyperLogLog for approximate unique user counts
// PFADD adds a user to the set, PFCOUNT returns approximate cardinality
public class HyperLogLogCounter
{
private readonly IConnectionMultiplexer _redis;
public async Task RecordUniqueAsync(string counterId, string userId)
{
var db = _redis.GetDatabase();
await db.HyperLogLogAddAsync($"{counterId}:hll", userId);
}
public async Task<long> GetUniqueCountAsync(string counterId)
{
var db = _redis.GetDatabase();
return await db.HyperLogLogLengthAsync($"{counterId}:hll");
}
// Merge multiple HyperLogLogs for cross-shard unique counts
public async Task<long> GetMergedUniqueCountAsync(
params string[] counterIds)
{
var db = _redis.GetDatabase();
var keys = counterIds
.Select(id => (RedisKey)$"{id}:hll")
.ToArray();
// Store merged result temporarily
var mergedKey = $"merged:{Guid.NewGuid()}";
await db.KeyMergeAsync(mergedKey, keys);
var count = await db.HyperLogLogLengthAsync(mergedKey);
await db.KeyDeleteAsync(mergedKey);
return count;
}
}
Redis Counter Pattern Comparison
| Pattern | Accuracy | Memory | Use Case |
|---|---|---|---|
| INCR + Sharding | Exact | 8 bytes per shard | Like counts, view counts |
| HyperLogLog | ~1% error | 12 KB fixed | Unique visitors, unique likers |
| Sorted Set (top-N) | Exact | O(N) per set | Leaderboards, trending counters |
| Bitmap | Exact | N/8 bytes | Daily active users per counter |
10. Database Counter Patterns
When Redis cannot hold the full counter dataset (cold data, historical counters), we fall back to the database. But naive database counters have serious problems.
The Naive Problem
-- THIS DOES NOT WORK AT SCALE
UPDATE counters SET count = count + 1 WHERE id = 'post:12345:likes';
-- Problem: Row-level lock, 1000 concurrent updates = queue of 1000 transactions
-- Result: P99 latency spikes to seconds under load
Optimistic Locking Pattern
-- Optimistic locking with version column
-- Step 1: Read current value
SELECT count, version FROM counters WHERE id = 'post:12345:likes';
-- Step 2: Update with version check
UPDATE counters
SET count = count + 1, version = version + 1
WHERE id = 'post:12345:likes' AND version = @expectedVersion;
-- Step 3: If affected_rows == 0, retry (another writer updated first)
Optimistic locking avoids long-held locks but still requires a retry loop under contention. For a counter receiving 10,000 writes/second on a single row, the retry rate becomes overwhelming.
Database Counter Anti-Pattern
Never use a single-row counter for high-throughput systems. Even with optimistic locking, a single row becomes a serialization bottleneck. The correct approach is sharding at the application level (splitting into N rows) or using a dedicated counter store like Redis.
Write-Ahead Log (WAL) for Durability
For maximum durability, every increment is first written to a WAL (Kafka) before being applied to the counter store. This provides at-least-once delivery — if the counter store fails, the WAL retains the event for replay.
sequenceDiagram
participant Client
participant API as Counter API
participant Kafka as Kafka WAL
participant Redis as Redis
participant DB as Cassandra
Client->>API: POST /increment (idempotency_key=abc)
API->>API: Check dedup cache for abc
alt Not duplicate
API->>Kafka: Produce(event_id=abc, delta=1)
Kafka-->>API: ACK
API->>Redis: INCR counter:shard:3
Redis-->>API: OK (count=4824)
API-->>Client: 202 Accepted (estimated=4824)
Note over Kafka,DB: Async background consumer
Kafka->>DB: INSERT INTO counter_events(event_id, delta)
else Duplicate
API-->>Client: 202 Accepted (estimated=4824, deduped=true)
end
11. Time-Windowed Counters
Many features require counts within a specific time window: "how many likes did this post get in the last 5 minutes?" or "what was the hourly view rate yesterday?" Time-windowed counters solve this by bucketing increments into fixed-size time windows.
flowchart TB
subgraph Events["Increment Events"]
E1["t=00:00:01 incr"]
E2["t=00:00:03 incr"]
E3["t=00:00:07 incr"]
E4["t=00:00:12 incr"]
E5["t=00:00:58 incr"]
end
subgraph Buckets["5-Second Buckets"]
B0["bucket:00-05 count=2"]
B1["bucket:05-10 count=1"]
B2["bucket:10-15 count=1"]
B3["bucket:55-00 count=1"]
end
subgraph Aggregation["Window Query (last 30s)"]
Q["Sum buckets 00-30 = 4"]
end
E1 & E2 --> B0
E3 --> B1
E4 --> B2
E5 --> B3
B0 & B1 & B2 --> Q
Bucket Key Design
// Time-windowed counter implementation
public class TimeWindowedCounter
{
private readonly IConnectionMultiplexer _redis;
public async Task IncrementAsync(string counterId, long delta = 1)
{
var db = _redis.GetDatabase();
var bucketKey = GetBucketKey(counterId, bucketSizeSeconds: 5);
await db.StringIncrementAsync(bucketKey, delta);
// Set TTL on bucket key (auto-cleanup after 24 hours)
await db.KeyExpireAsync(bucketKey, TimeSpan.FromHours(24));
}
public async Task<long> GetCountInWindowAsync(
string counterId, int windowSeconds)
{
var db = _redis.GetDatabase();
var now = DateTimeOffset.UtcNow;
var startBucket = now.AddSeconds(-windowSeconds);
var tasks = new List<Task<RedisValue>>();
for (var t = startBucket; t <= now; t = t.AddSeconds(5))
{
var key = GetBucketKey(counterId, t, bucketSizeSeconds: 5);
tasks.Add(db.StringGetAsync(key));
}
var values = await Task.WhenAll(tasks);
return values.Where(v => v.HasValue).Sum(v => (long)v);
}
private string GetBucketKey(
string counterId, DateTimeOffset time, int bucketSizeSeconds)
{
var bucketStart = new DateTimeOffset(
time.Year, time.Month, time.Day,
time.Hour, time.Minute, time.Second, time.Offset);
var bucketId = (long)(bucketStart.ToUnixTimeSeconds()
/ bucketSizeSeconds);
return $"{counterId}:bucket:{bucketId}";
}
private string GetBucketKey(string counterId, int bucketSizeSeconds)
=> GetBucketKey(counterId, DateTimeOffset.UtcNow, bucketSizeSeconds);
}
Multi-Resolution Buckets
| Resolution | Bucket Size | Retention | Storage per Counter | Use Case |
|---|---|---|---|---|
| 5-second | 5 seconds | 1 hour | 720 buckets × 8B = 5.7 KB | Real-time trending |
| 1-minute | 1 minute | 24 hours | 1,440 buckets × 8B = 11.5 KB | Hourly analytics |
| 1-hour | 1 hour | 30 days | 720 buckets × 8B = 5.7 KB | Daily analytics |
| 1-day | 1 day | 1 year | 365 buckets × 8B = 2.9 KB | Historical analytics |
12. Approximate Counting
For many use cases, exact counts are unnecessary and the performance cost is unjustified. A YouTube video with 500 million views does not need to know it has exactly 500,123,456 — "approximately 500M" is sufficient. Approximate counting techniques provide massive performance and memory savings.
Count-Min Sketch
flowchart TB
subgraph CMS["Count-Min Sketch (width=4, depth=3)"]
direction TB
Row1["Hash₁: [0,0,0,0]"]
Row2["Hash₂: [0,0,0,0]"]
Row3["Hash₃: [0,0,0,0]"]
end
subgraph Increment["On increment(item)"]
H1["Hash₁('post:123') → col 2"]
H2["Hash₂('post:123') → col 1"]
H3["Hash₃('post:123') → col 3"]
end
subgraph Result["After increments"]
R1["Hash₁: [0,0,3,0]"]
R2["Hash₂: [0,2,0,0]"]
R3["Hash₃: [0,0,0,4]"]
end
Increment --> CMS
CMS --> Result
The Count-Min Sketch is a probabilistic data structure that estimates event frequencies using a 2D array of counters and multiple hash functions. It overestimates (never underestimates) and the error bound decreases exponentially with the width of the sketch. For 1 million counters with 1% error, a Count-Min Sketch requires only ~1.2 MB of memory — compared to 8 MB for exact counters.
Comparison of Counting Techniques
| Technique | Memory | Accuracy | Operations | When to Use |
|---|---|---|---|---|
| Exact Counter | O(N) | 100% | INCR, GET | Low cardinality, exact needs |
| Count-Min Sketch | O(W × D) | ~1% error | UPDATE, ESTIMATE | Frequent items, frequency queries |
| HyperLogLog | 12 KB fixed | ~1% error | ADD, COUNT | Unique cardinality estimation |
| LogLog | 4 KB fixed | ~5% error | ADD, COUNT | Very large cardinality, less precision |
| Bloom Filter | O(N) bits | ~1% FP | ADD, CONTAINS | Membership tests (deduplication) |
13. Exactly-Once Increment
Network retries, at-least-once delivery, and client bugs all contribute to duplicate increments. Without deduplication, a user who presses "like" twice due to a network retry would see the count increase by 2. Exactly-once semantics require a deduplication layer.
flowchart LR
subgraph Client["Client"]
C["Like Button Press"]
end
subgraph Dedup["Deduplication Layer"]
BF["Bloom Filter (fast reject)"]
KV["Redis SET (exact check)"]
DB["Dedup Store (persistent)"]
end
subgraph Counter["Counter"]
INCR["Increment Counter"]
end
C -->|"idempotency_key=evt-abc-123"| BF
BF -->|"Definitely not seen"| KV
BF -->|"Possibly seen → still check KV"| KV
KV -->|"Not in set → first time"| INCR
KV -->|"Already in set → DUPLICATE"| DROP["Drop Request"]
INCR -->|"Store key in SET"| KV
Deduplication Implementation
public class IdempotencyDeduplicator
{
private readonly IConnectionMultiplexer _redis;
private readonly BloomFilter _bloomFilter; // In-memory probabilistic filter
private readonly TimeSpan _ttl = TimeSpan.FromHours(24);
public async Task<bool> IsDuplicateAsync(string idempotencyKey)
{
// Layer 1: Fast probabilistic check (Bloom Filter)
// If BF says "definitely not seen" → not a duplicate
if (!_bloomFilter.MightContain(idempotencyKey))
{
_bloomFilter.Add(idempotencyKey);
return false; // Definitely new
}
// Layer 2: Exact check in Redis
var db = _redis.GetDatabase();
var isNew = await db.StringSetAsync(
$"dedup:{idempotencyKey}",
"1",
_ttl,
When.NotExists);
return !isNew; // true = duplicate, false = new
}
}
// Usage in the increment handler
public async Task<IncrementResult> HandleIncrementAsync(IncrementRequest req)
{
if (await _deduplicator.IsDuplicateAsync(req.IdempotencyKey))
{
return new IncrementResult { Deduped = true, EstimatedCount = await _counter.GetCountAsync(req.CounterId) };
}
await _counter.IncrementAsync(req.CounterId, req.Delta);
return new IncrementResult { Deduped = false, EstimatedCount = await _counter.GetCountAsync(req.CounterId) };
}
14. Counter Aggregation Pipeline
As counter shards grow, reading all shards and summing them on every request becomes expensive. The aggregation pipeline pre-computes sums periodically and stores them as cached aggregates.
flowchart LR
subgraph Sources["Counter Sources"]
S0["Shard 0"]
S1["Shard 1"]
S2["Shard 2"]
SN["Shard N"]
end
subgraph Flink["Apache Flink Streaming"]
Source["Kafka Source"]
Window["Tumbling Window (5s)"]
KeyBy["Group By counter_id"]
Sum["Sum(delta) per counter"]
Sink["Kafka Sink"]
end
subgraph Cache["Aggregation Cache"]
Redis["Redis: counter:total = sum"]
MemCache["L1: In-Process Cache"]
end
S0 & S1 & S2 & SN -->|"increment events"| Source
Source --> Window --> KeyBy --> Sum --> Sink
Sink -->|"aggregated deltas"| Redis
Redis --> MemCache
The Flink streaming job reads increment events from Kafka, windows them into 5-second tumbling windows, groups by counter ID, sums the deltas, and writes the aggregated result back to Redis. This means that reads never need to fan out to all shards — they simply read the pre-aggregated value from Redis.
15. Fan-Out for High Write Throughput
Fan-out is the process of distributing a single write across multiple storage nodes. In our counter system, fan-out happens at two levels:
Two Levels of Fan-Out
- Shard fan-out: A single counter's increments are distributed across N shard nodes, eliminating the hot partition. This is handled by the shard router.
- Replica fan-out: Each shard is replicated across M nodes (typically 3) for fault tolerance. Write acks require W of M replicas to confirm, and reads can be served by any R replicas where W + R > M.
flowchart TB
subgraph Writer["Write Path"]
W["Counter Increment"]
end
subgraph ShardLayer["Shard Fan-Out (N=10)"]
S0["Shard 0"]
S1["Shard 1"]
S2["Shard 2"]
S3["Shard 3"]
end
subgraph ReplLayer["Replica Fan-Out (M=3, W=2)"]
R0["Replica 0A (leader)"]
R1["Replica 0B (follower)"]
R2["Replica 0C (follower)"]
end
W -->|"hash routing"| S0
S0 -->|"sync replication"| R0
R0 -->|"async replication"| R1
R0 -->|"async replication"| R2
The combination of shard fan-out and replica fan-out means a single increment to a hot counter is distributed across up to N × M = 30 storage nodes, with each node handling a fraction of the total write load.
16. Read Path Optimization
The read path for counters is heavily optimized with multi-tier caching. The key insight is that most counter reads are for the same few "hot" counters (viral posts, trending videos), so a small cache serves the vast majority of reads.
Stale-While-Revalidate Pattern
flowchart TB
subgraph ReadPath["Read Path"]
Client["GET /counter/id"]
L1["L1: In-Process Cache (TTL=1s)"]
L2["L2: Redis (TTL=5s)"]
L3["L3: Cassandra"]
Revalidate["Background Revalidator"]
end
Client --> L1
L1 -->|"HIT (1ms)"| Response
L1 -->|"MISS"| L2
L2 -->|"HIT (3ms)"| Response
L2 -->|"MISS"| L3
L3 -->|"HIT (15ms)"| Response
L3 -->|"MISS"| NotFound
L2 -->|"Trigger async"| Revalidate
Revalidate -->|"Pre-warm L1"| L1
Response["Return Count"]
The stale-while-revalidate pattern serves the stale cached value immediately and triggers an async background refresh. This ensures reads always return quickly (even if the cache is expired), while freshness is improved opportunistically.
Cache Hit Rate Optimization
| Tier | Size | TTL | Latency | Hit Rate |
|---|---|---|---|---|
| L1 In-Process | 10K entries (~800 KB) | 1 second | <0.1ms | ~60% |
| L2 Redis | 1M entries (~80 MB) | 5 seconds | 1-3ms | ~35% |
| L3 Database | All counters | N/A | 5-20ms | ~5% |
| Total | P50: 0.5ms | 100% |
17. Cross-Region Counter Replication
When users in multiple regions increment the same counter, each region maintains a local copy and asynchronously replicates to a global aggregation point.
flowchart TB
subgraph US["US-East Region"]
US_DB["Local Counter DB"]
US_Counter["US Counter (count=5000)"]
end
subgraph EU["EU-West Region"]
EU_DB["Local Counter DB"]
EU_Counter["EU Counter (count=3000)"]
end
subgraph AP["APAC Region"]
AP_DB["Local Counter DB"]
AP_Counter["AP Counter (count=2000)"]
end
subgraph Global["Global Aggregator"]
GA["Global Aggregate (count=10000)"]
GRedis["Global Redis"]
end
US_Counter -->|"async every 5s"| GA
EU_Counter -->|"async every 5s"| GA
AP_Counter -->|"async every 5s"| GA
GA --> GRedis
GRedis -->|"eventual sync"| US_Counter & EU_Counter & AP_Counter
Each region operates independently for writes (no cross-region coordination for increments). A global aggregator periodically sums the regional counts and publishes the global total back to all regions. This gives users in any region a "close enough" global count within a few seconds of staleness.
18. Anti-Abuse & Rate Limiting
Distributed counters are targets for abuse: bots inflating like counts, spam accounts generating fake views, and coordinated campaigns manipulating trending algorithms. An effective counter system must include anti-abuse measures.
Multi-Layer Anti-Abuse
| Layer | Technique | Latency | Catches |
|---|---|---|---|
| L1: Rate Limiting | Token bucket per user/IP | <1ms | High-frequency bots |
| L2: Pattern Detection | Sliding window anomaly detection | 5-10ms | Burst patterns, coordinated attacks |
| L3: Behavioral Analysis | User session analysis (no interaction before like) | 50ms | Click farms, automated tools |
| L4: ML Classification | Real-time ML model for bot detection | 20-50ms | Sophisticated bots, AI-generated activity |
| L5: Post-Hoc Audit | Batch analysis of suspicious patterns | Hours | Coordinated inauthentic behavior |
Critical: Never Trust Client-Side Counts
The counter increment must only be processed server-side after validation. The client sends an event, and the server decides whether to count it based on authentication, rate limiting, and abuse detection. A client-side counter can be trivially manipulated.
19. Database Design
Primary Schema (Cassandra/DynamoDB)
-- Cassandra schema for sharded counters
CREATE TABLE counter_shards (
counter_id TEXT,
shard_index INT,
count BIGINT,
version BIGINT,
updated_at TIMESTAMP,
PRIMARY KEY (counter_id, shard_index)
) WITH CLUSTERING ORDER BY (shard_index ASC);
-- Counter metadata
CREATE TABLE counter_metadata (
counter_id TEXT PRIMARY KEY,
entity_type TEXT,
entity_id TEXT,
counter_type TEXT, -- 'exact', 'approximate', 'unique'
num_shards INT,
created_at TIMESTAMP,
updated_at TIMESTAMP
);
-- Time-windowed buckets
CREATE TABLE counter_buckets (
counter_id TEXT,
bucket_key BIGINT, -- unix_timestamp / bucket_size_seconds
bucket_size INT, -- 5, 60, 3600, 86400
count BIGINT,
PRIMARY KEY (counter_id, bucket_key, bucket_size)
);
-- Increment events for deduplication
CREATE TABLE increment_events (
idempotency_key TEXT PRIMARY KEY,
counter_id TEXT,
delta BIGINT,
user_id TEXT,
created_at TIMESTAMP
) WITH default_time_to_live = 86400; -- 24-hour TTL
20. Caching Strategy
The caching strategy follows a multi-tier approach with each tier serving a specific purpose:
flowchart TB
subgraph L1["L1: In-Process Cache"]
MC["MemoryCache / ConcurrentDictionary"]
TTL1["TTL: 1 second"]
Size1["10K entries (~800 KB)"]
end
subgraph L2["L2: Redis Cluster"]
RC["Redis Cluster (16 nodes)"]
TTL2["TTL: 5-30 seconds"]
Size2["1M entries (~80 MB)"]
end
subgraph L3["L3: Database"]
DB["Cassandra / DynamoDB"]
TTL3["Permanent"]
Size3["All data"]
end
L1 -->|"miss → 0.1ms"| L2
L2 -->|"miss → 3ms"| L3
L3 -->|"backfill → 15ms"| L2
L2 -->|"backfill → 3ms"| L1
Cache Invalidation Strategies
| Strategy | When to Invalidate | Pros | Cons |
|---|---|---|---|
| TTL-Based | After fixed duration | Simple, predictable | May serve stale data |
| Write-Through | On every write | Always fresh | Write latency increases |
| Write-Behind | After batch flush | Write latency stays low | Stale during flush interval |
| Event-Driven | On cache event from Kafka | Near real-time freshness | Complex infrastructure |
21. Multi-Region Design
In a multi-region deployment, the fundamental challenge is: how do you maintain a global counter when users in Tokyo and New York are incrementing the same counter independently?
Conflict Resolution for Counters
Counters have a unique advantage in conflict resolution: increments are commutative. If Region A increments by 5 and Region B increments by 3, the final count is 8 regardless of the order. This means simple vector clock reconciliation or last-writer-wins is sufficient — there are no conflicting values to resolve.
Counter Commutativity
Unlike a shopping cart (where add/remove operations must be carefully ordered), counter increments are purely additive. This makes counter replication significantly simpler than general-purpose replication. Each region can independently increment its local counter, and the global count is simply the sum of all regional counts. No distributed locks, no consensus protocols, no conflict resolution needed.
22. Cost Estimation
| Component | Spec | Monthly Cost (est.) |
|---|---|---|
| Redis Cluster (16 nodes) | 4 GB RAM each | $3,200 |
| Cassandra Cluster | 6 nodes × 1 TB SSD | $2,400 |
| Kafka Cluster | 6 brokers × 1 TB SSD | $1,800 |
| Counter API Servers | 8 × 4 vCPU, 16 GB RAM | $2,000 |
| Flink Cluster | 4 TaskManagers × 8 GB | $1,200 |
| Load Balancers + Network | Multi-region | $800 |
| Total | $11,400/month |
23. Interview Q&A (10+ Questions)
Answer: First, shard the counter into N=20 shards. Each shard receives ~50K increments/second instead of 1M on a single row. Use Redis INCR on each shard for sub-millisecond writes. Apply coalescing at the application layer — batch and merge increments before flushing to durable storage. Use in-process L1 cache for reads to avoid Redis round-trips for hot counters. The aggregate count is computed asynchronously every 5 seconds.
Answer: Eventual consistency, without hesitation. Strong consistency for a counter requires synchronous cross-shard coordination (2PC or consensus), which reduces throughput by 10-100x and creates availability risks. A like count being off by 1-2 for 1-5 seconds is imperceptible to users. The cost savings from eventual consistency fund the infrastructure for 100x more throughput.
Answer: True exactly-once is impossible across network boundaries. Instead, we implement effectively exactly-once via: (1) Idempotency keys — each increment carries a unique key stored with a 24-hour TTL. (2) A two-layer dedup: fast Bloom filter for probabilistic rejection, exact Redis SET for confirmation. (3) Kafka's exactly-once semantics within the streaming pipeline. This combination ensures no increment is counted twice under normal failure modes.
Answer: Use HyperLogLog, a probabilistic data structure that estimates cardinality with ~1% error using only 12 KB of memory. Each user ID is added to the HLL set via PFADD, and the count is returned via PFCOUNT. For cross-shard unique counts, merge HLL sets using PFMERGE. The trade-off: we lose exactness but gain O(1) memory per counter regardless of user count.
Answer: Redis Cluster replicates each shard to 2 followers. If the leader fails, a follower is promoted within seconds. For the gap period: (1) Writes can be buffered in the write-behind queue (Kafka WAL). (2) Reads fall back to L3 (database) or return the last-known aggregate. (3) Once the new leader is up, the WAL is replayed to fill in any missed increments. No data is lost, but there may be a brief period of higher read latency.
Answer: Create a Redis key per time bucket: counter_id:bucket:floor(unix_ts / 5). On increment, write to the current bucket. On read, sum all bucket keys within the window. Set a TTL on each bucket for automatic cleanup (24 hours for 5-second buckets). For analytics across many counters, stream bucket updates to Flink for pre-aggregation.
Answer: Multi-layer defense: (1) Rate limiting per user/IP (token bucket, 10 likes/minute). (2) Authentication verification — only logged-in users can like. (3) Behavioral analysis — check that the user browsed the post before liking. (4) ML-based bot detection model scoring each increment in real-time. (5) Post-hoc batch audit to detect coordinated inauthentic behavior patterns.
Answer: Count-Min Sketch estimates the frequency of each item in a stream (e.g., "how many times was post X liked?"). It uses a 2D array of counters with multiple hash functions, overestimates frequencies, and uses O(W × D) space. HyperLogLog estimates the cardinality of a set (e.g., "how many unique users liked post X?"). It uses hash-based probabilistic counting in O(1) fixed space (12 KB). They solve different problems.
Answer: (1) Check L1 in-process cache for all 1000 counter IDs. (2) For cache misses, batch-read from Redis using MGET. (3) Any remaining misses fall back to database reads. (4) Return the merged results. To avoid thundering herd on cache miss, implement request coalescing — if 100 requests for the same counter arrive during a cache miss, only 1 database query is made and all 100 requests share the result. Use Redis pipeline for the batch read to keep it in 1 round-trip.
Answer: Each region maintains its own local counter for fast local writes. A global aggregator (running in one region or a separate region) periodically (every 5 seconds) sums regional counts and publishes the global total back to all regions. Since increments are commutative (A + B = B + A), there are no conflict resolution issues. Users in any region see: local_count + (global_total - their_region_count), giving a near-real-time global count.
Answer: Cassandra offers better write throughput with its LSM-tree storage engine, tunable consistency levels, and no per-request pricing. Ideal for extremely high write volumes. DynamoDB offers auto-scaling, built-in DynamoDB Streams for event-driven patterns, and lower operational overhead. However, DynamoDB's per-request pricing can become expensive at 1M+ writes/sec (potentially $10K+/month for counter writes alone). For our use case, Cassandra is more cost-effective at scale, while DynamoDB is better for teams that want to minimize ops burden.
Answer: Three techniques: (1) Request coalescing — use a mutex/lock per counter ID; only one request actually queries the database, others wait and share the result. (2) Probabilistic early expiration (PET) — instead of all requests hitting the TTL at the same time, each request has a probability of triggering a refresh before TTL expires, spreading the refresh load. (3) Stale-while-revalidate — serve the stale value immediately and refresh in the background, so no request ever blocks on a cache miss.
Answer: Use two parallel storage mechanisms: (1) Exact count via sharded Redis INCR — 10 shards per counter, sum on read. Provides exact like counts. (2) Approximate unique viewers via HyperLogLog — each viewer ID is PFADD'd to an HLL set, PFCOUNT returns ~1% accurate unique count. Both are updated on every event, but use different data structures optimized for their respective query patterns. The HLL uses 12 KB per counter regardless of viewer count, while the exact counter uses 80 bytes (10 × 8 bytes) regardless of like count.
24. Full C# Implementation
Below is a production-grade C# implementation covering all core components: sharded counters, HyperLogLog approximate counting, time-windowed counters, deduplication, caching, and the counter service facade.
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using StackExchange.Redis;
namespace DistributedCounter
{
#region Core Models
public class CounterConfig
{
public int NumShards { get; set; } = 10;
public TimeSpan ShardCacheTtl { get; set; } = TimeSpan.FromSeconds(1);
public TimeSpan AggregateCacheTtl { get; set; } = TimeSpan.FromSeconds(5);
public TimeSpan IdempotencyTtl { get; set; } = TimeSpan.FromHours(24);
public int BloomFilterCapacity { get; set; } = 1_000_000;
public double BloomFilterErrorRate { get; set; } = 0.001;
public int[] TimeWindowSizes { get; set; } = { 5, 60, 3600 };
}
public class IncrementResult
{
public string CounterId { get; set; } = string.Empty;
public long EstimatedCount { get; set; }
public bool IsDeduped { get; set; }
public int ShardIndex { get; set; }
public DateTime ServerTimestamp { get; set; }
}
public class CountResult
{
public string CounterId { get; set; } = string.Empty;
public long Count { get; set; }
public string Accuracy { get; set; } = "exact";
public DateTime ComputedAt { get; set; }
}
public class TimeWindowedCountResult
{
public string CounterId { get; set; } = string.Empty;
public long Count { get; set; }
public int WindowSeconds { get; set; }
public DateTime ComputedAt { get; set; }
}
#endregion
#region HyperLogLog Implementation
public class HyperLogLog
{
private readonly int _precision;
private readonly int[] _registers;
public HyperLogLog(int precision = 14)
{
if (precision < 4 || precision > 16)
throw new ArgumentException("Precision must be 4-16");
_precision = precision;
_registers = new int[1 << precision];
}
public void Add(byte[] data)
{
var hash = MurmurHash3(data);
int index = (int)((uint)hash >> (32 - _precision));
int remaining = hash << _precision | (1 << (_precision - 1));
int leadingZeros = CountLeadingZeros(remaining) + 1;
_registers[index] = Math.Max(_registers[index], leadingZeros);
}
public void Add(string value)
=> Add(Encoding.UTF8.GetBytes(value));
public long Count()
{
double alpha;
int m = _registers.Length;
if (m == 16) alpha = 0.673;
else if (m == 32) alpha = 0.697;
else if (m == 64) alpha = 0.709;
else alpha = 0.7213 / (1.0 + 1.079 / m);
double sum = _registers.Sum(r => Math.Pow(2.0, -r));
double estimate = alpha * m * m / sum;
if (estimate <= 2.5 * m)
{
int zeros = _registers.Count(r => r == 0);
if (zeros > 0) return (long)Math.Round(m * Math.Log((double)m / zeros));
}
if (estimate > (1L << 32) / 30.0)
return (long)(-Math.Pow(2.0, 32) * Math.Log(1.0 - estimate / Math.Pow(2.0, 32)));
return (long)Math.Round(estimate);
}
public static HyperLogLog Merge(HyperLogLog a, HyperLogLog b)
{
if (a._precision != b._precision)
throw new ArgumentException("HLL precision mismatch");
var merged = new HyperLogLog(a._precision);
for (int i = 0; i < a._registers.Length; i++)
merged._registers[i] = Math.Max(a._registers[i], b._registers[i]);
return merged;
}
public byte[] Serialize()
{
var bytes = new byte[_registers.Length * 4];
Buffer.BlockCopy(_registers, 0, bytes, 0, bytes.Length);
return bytes;
}
public static HyperLogLog Deserialize(byte[] data, int precision = 14)
{
var hll = new HyperLogLog(precision);
Buffer.BlockCopy(data, 0, hll._registers, 0, data.Length);
return hll;
}
private static int MurmurHash3(byte[] data)
{
const uint c1 = 0xcc9e2d51;
const uint c2 = 0x1b873593;
uint h1 = 0;
int nblocks = data.Length / 4;
for (int i = 0; i < nblocks; i++)
{
uint k1 = BitConverter.ToUInt32(data, i * 4);
k1 *= c1; k1 = RotateLeft(k1, 15); k1 *= c2;
h1 ^= k1; h1 = RotateLeft(h1, 13); h1 = h1 * 5 + 0xe6546b64;
}
uint k1 = 0;
int tail = nblocks * 4;
switch (data.Length - tail)
{
case 3: k1 ^= (uint)data[tail + 2] << 16; goto case 2;
case 2: k1 ^= (uint)data[tail + 1] << 8; goto case 1;
case 1: k1 ^= data[tail]; k1 *= c1; k1 = RotateLeft(k1, 15); k1 *= c2; h1 ^= k1; break;
}
h1 ^= (uint)data.Length;
h1 ^= h1 >> 16; h1 *= 0x85ebca6b; h1 ^= h1 >> 13; h1 *= 0xc2b2ae35;
h1 ^= h1 >> 16;
return (int)h1;
}
private static uint RotateLeft(uint x, byte r) => (x << r) | (x >> (32 - r));
private static int CountLeadingZeros(int value)
{
if (value == 0) return 32;
int count = 0;
uint u = (uint)value;
if ((u & 0xFFFF0000) == 0) { count += 16; u <<= 16; }
if ((u & 0xFF000000) == 0) { count += 8; u <<= 8; }
if ((u & 0xF0000000) == 0) { count += 4; u <<= 4; }
if ((u & 0xC0000000) == 0) { count += 2; u <<= 2; }
if ((u & 0x80000000) == 0) { count += 1; }
return count;
}
}
#endregion
#region Bloom Filter for Deduplication
public class BloomFilter
{
private readonly BitArray _bits;
private readonly int _numHashFunctions;
private readonly int _size;
public BloomFilter(int capacity, double errorRate)
{
_size = (int)(-capacity * Math.Log(errorRate) / (Math.Log(2) * Math.Log(2)));
_numHashFunctions = (int)(Math.Log(2) * _size / capacity);
_bits = new BitArray(_size);
}
public void Add(string item)
{
var hashes = GetHashes(item);
for (int i = 0; i < _numHashFunctions; i++)
_bits[Math.Abs(hashes[i] % _size)] = true;
}
public bool MightContain(string item)
{
var hashes = GetHashes(item);
for (int i = 0; i < _numHashFunctions; i++)
if (!_bits[Math.Abs(hashes[i] % _size)]) return false;
return true;
}
private int[] GetHashes(string item)
{
var data = Encoding.UTF8.GetBytes(item);
var hashes = new int[_numHashFunctions];
for (int i = 0; i < _numHashFunctions; i++)
{
using var md5 = MD5.Create();
var seed = md5.ComputeHash(data.Concat(BitConverter.GetBytes(i)).ToArray());
hashes[i] = BitConverter.ToInt32(seed, 0);
}
return hashes;
}
}
#endregion
#region Coalescing Write-Behind Queue
public class CoalescingWriteBehindQueue
{
private readonly ConcurrentDictionary<string, long> _pendingDeltas = new();
private readonly Timer _flushTimer;
private readonly Func<string, long, Task> _flushCallback;
private readonly int _flushIntervalMs;
public CoalescingWriteBehindQueue(
Func<string, long, Task> flushCallback,
int flushIntervalMs = 5000)
{
_flushCallback = flushCallback;
_flushIntervalMs = flushIntervalMs;
_flushTimer = new Timer(
async _ => await FlushAsync(),
null,
flushIntervalMs,
flushIntervalMs);
}
public void Enqueue(string counterId, long delta)
{
_pendingDeltas.AddOrUpdate(counterId, delta,
(_, existing) => Interlocked.Add(ref existing, delta));
}
public int PendingCount => _pendingDeltas.Count;
public async Task FlushAsync()
{
var snapshot = new Dictionary<string, long>(_pendingDeltas);
_pendingDeltas.Clear();
foreach (var (counterId, totalDelta) in snapshot)
{
try
{
await _flushCallback(counterId, totalDelta);
}
catch (Exception)
{
_pendingDeltas.AddOrUpdate(counterId, totalDelta,
(_, existing) => Interlocked.Add(ref existing, totalDelta));
}
}
}
}
#endregion
#region Sharded Counter
public class ShardedCounter
{
private readonly IConnectionMultiplexer _redis;
private readonly CounterConfig _config;
private readonly ConcurrentDictionary<string, long> _localCache = new();
private readonly Timer _cacheRefreshTimer;
public ShardedCounter(IConnectionMultiplexer redis, CounterConfig config)
{
_redis = redis;
_config = config;
_cacheRefreshTimer = new Timer(
async _ => await RefreshHotCountersAsync(),
null,
config.ShardCacheTtl,
config.ShardCacheTtl);
}
public async Task<IncrementResult> IncrementAsync(
string counterId, long delta = 1, string? idempotencyKey = null)
{
var db = _redis.GetDatabase();
var shard = GetRandomShard(counterId);
var shardKey = GetShardKey(counterId, shard);
await db.StringIncrementAsync(shardKey, delta);
await db.KeyExpireAsync(shardKey, TimeSpan.FromDays(7));
var estimatedCount = await GetCountAsync(counterId);
return new IncrementResult
{
CounterId = counterId,
EstimatedCount = estimatedCount,
ShardIndex = shard,
ServerTimestamp = DateTime.UtcNow
};
}
public async Task<long> GetCountAsync(string counterId)
{
var cacheKey = $"{counterId}:aggregate";
if (_localCache.TryGetValue(cacheKey, out var cached))
return cached;
var db = _redis.GetDatabase();
var keys = Enumerable.Range(0, _config.NumShards)
.Select(i => (RedisKey)GetShardKey(counterId, i))
.ToArray();
var values = await db.StringGetAsync(keys);
var total = values.Sum(v => v.HasValue ? (long)v : 0);
_localCache[cacheKey] = total;
return total;
}
public async Task<CountResult> GetCountWithMetaAsync(string counterId)
{
var count = await GetCountAsync(counterId);
return new CountResult
{
CounterId = counterId,
Count = count,
Accuracy = "exact",
ComputedAt = DateTime.UtcNow
};
}
private async Task RefreshHotCountersAsync()
{
var keysToRefresh = _localCache.Keys
.Where(k => k.EndsWith(":aggregate"))
.Select(k => k.Replace(":aggregate", ""))
.Take(1000)
.ToList();
foreach (var counterId in keysToRefresh)
{
try
{
var count = await GetCountFromRedisAsync(counterId);
_localCache[$"{counterId}:aggregate"] = count;
}
catch { /* best effort */ }
}
}
private async Task<long> GetCountFromRedisAsync(string counterId)
{
var db = _redis.GetDatabase();
var keys = Enumerable.Range(0, _config.NumShards)
.Select(i => (RedisKey)GetShardKey(counterId, i))
.ToArray();
var values = await db.StringGetAsync(keys);
return values.Sum(v => v.HasValue ? (long)v : 0);
}
private int GetRandomShard(string counterId)
{
var combined = $"{counterId}:{Random.Shared.Next(_config.NumShards)}";
var hash = MurmurHash3String(combined);
return Math.Abs(hash) % _config.NumShards;
}
private static string GetShardKey(string counterId, int shard)
=> $"counter:{counterId}:shard:{shard}";
private static int MurmurHash3String(string value)
{
var data = Encoding.UTF8.GetBytes(value);
using var md5 = MD5.Create();
var hash = md5.ComputeHash(data);
return BitConverter.ToInt32(hash, 0);
}
}
#endregion
#region Time-Windowed Counter
public class TimeWindowedCounter
{
private readonly IConnectionMultiplexer _redis;
private readonly CounterConfig _config;
public TimeWindowedCounter(IConnectionMultiplexer redis, CounterConfig config)
{
_redis = redis;
_config = config;
}
public async Task IncrementAsync(string counterId, long delta = 1)
{
var db = _redis.GetDatabase();
foreach (var windowSize in _config.TimeWindowSizes)
{
var bucketKey = GetBucketKey(counterId, windowSize);
await db.StringIncrementAsync(bucketKey, delta);
await db.KeyExpireAsync(bucketKey,
TimeSpan.FromSeconds(windowSize * 100));
}
}
public async Task<TimeWindowedCountResult> GetCountInWindowAsync(
string counterId, int windowSeconds)
{
var db = _redis.GetDatabase();
var bucketSize = GetBucketSizeForWindow(windowSeconds);
var now = DateTimeOffset.UtcNow;
var startBucket = now.AddSeconds(-windowSeconds);
var tasks = new List<Task<RedisValue>>();
for (var t = startBucket; t <= now; t = t.AddSeconds(bucketSize))
{
var key = GetBucketKey(counterId, t, bucketSize);
tasks.Add(db.StringGetAsync(key));
}
var values = await Task.WhenAll(tasks);
var total = values.Where(v => v.HasValue).Sum(v => (long)v);
return new TimeWindowedCountResult
{
CounterId = counterId,
Count = total,
WindowSeconds = windowSeconds,
ComputedAt = DateTime.UtcNow
};
}
private string GetBucketKey(string counterId, int bucketSizeSeconds)
=> GetBucketKey(counterId, DateTimeOffset.UtcNow, bucketSizeSeconds);
private static string GetBucketKey(
string counterId, DateTimeOffset time, int bucketSizeSeconds)
{
var unixSeconds = time.ToUnixTimeSeconds();
var bucketId = unixSeconds / bucketSizeSeconds;
return $"counter:{counterId}:bucket:{bucketSizeSeconds}:{bucketId}";
}
private int GetBucketSizeForWindow(int windowSeconds)
{
if (windowSeconds <= 60) return 5;
if (windowSeconds <= 3600) return 60;
return 3600;
}
}
#endregion
#region Idempotency Deduplicator
public class IdempotencyDeduplicator
{
private readonly IConnectionMultiplexer _redis;
private readonly BloomFilter _bloomFilter;
private readonly CounterConfig _config;
public IdempotencyDeduplicator(
IConnectionMultiplexer redis, CounterConfig config)
{
_redis = redis;
_config = config;
_bloomFilter = new BloomFilter(
config.BloomFilterCapacity, config.BloomFilterErrorRate);
}
public async Task<bool> IsDuplicateAsync(string idempotencyKey)
{
if (!_bloomFilter.MightContain(idempotencyKey))
{
_bloomFilter.Add(idempotencyKey);
return false;
}
var db = _redis.GetDatabase();
var key = $"dedup:{idempotencyKey}";
var isNew = await db.StringSetAsync(
key, "1", _config.IdempotencyTtl, When.NotExists);
return !isNew;
}
}
#endregion
#region Counter Aggregator
public class CounterAggregator
{
private readonly IConnectionMultiplexer _redis;
private readonly ConcurrentDictionary<string, long> _aggregateCache = new();
private readonly Timer _recomputeTimer;
public CounterAggregator(IConnectionMultiplexer redis, int recomputeIntervalMs = 5000)
{
_redis = redis;
_recomputeTimer = new Timer(
async _ => await RecomputeAggregatesAsync(),
null,
recomputeIntervalMs,
recomputeIntervalMs);
}
public async Task UpdateAggregateAsync(
string counterId, string shardKey, long delta)
{
var db = _redis.GetDatabase();
var aggregateKey = $"counter:{counterId}:aggregate:total";
await db.StringIncrementAsync(aggregateKey, delta);
await db.KeyExpireAsync(aggregateKey, TimeSpan.FromHours(24));
_aggregateCache[counterId] =
(long)await db.StringGetAsync(aggregateKey);
}
public long GetCachedAggregate(string counterId)
{
return _aggregateCache.TryGetValue(counterId, out var count)
? count : -1;
}
private async Task RecomputeAggregatesAsync()
{
var hotCounters = _aggregateCache.Keys.Take(1000).ToList();
var db = _redis.GetDatabase();
foreach (var counterId in hotCounters)
{
try
{
var keys = Enumerable.Range(0, 10)
.Select(i => (RedisKey)$"counter:{counterId}:shard:{i}")
.ToArray();
var values = await db.StringGetAsync(keys);
var total = values
.Sum(v => v.HasValue ? (long)v : 0);
var aggregateKey = $"counter:{counterId}:aggregate:total";
await db.StringSetAsync(aggregateKey, total);
_aggregateCache[counterId] = total;
}
catch { /* best effort recomputation */ }
}
}
}
#endregion
#region CounterService Facade
public class CounterService
{
private readonly ShardedCounter _shardedCounter;
private readonly TimeWindowedCounter _timeWindowedCounter;
private readonly IdempotencyDeduplicator _deduplicator;
private readonly CounterAggregator _aggregator;
private readonly HyperLogLog _hyperLogLog;
private readonly CounterConfig _config;
public CounterService(
IConnectionMultiplexer redis,
CounterConfig? config = null)
{
_config = config ?? new CounterConfig();
_shardedCounter = new ShardedCounter(redis, _config);
_timeWindowedCounter = new TimeWindowedCounter(redis, _config);
_deduplicator = new IdempotencyDeduplicator(redis, _config);
_aggregator = new CounterAggregator(redis);
_hyperLogLog = new HyperLogLog(14);
}
public async Task<IncrementResult> IncrementAsync(
string counterId,
string userId,
long delta = 1,
string? idempotencyKey = null)
{
var key = idempotencyKey ?? $"{counterId}:{userId}:{DateTimeOffset.UtcNow.ToUnixTimeSeconds()}";
if (await _deduplicator.IsDuplicateAsync(key))
{
var existing = await _shardedCounter.GetCountAsync(counterId);
return new IncrementResult
{
CounterId = counterId,
EstimatedCount = existing,
IsDeduped = true,
ServerTimestamp = DateTime.UtcNow
};
}
var result = await _shardedCounter.IncrementAsync(counterId, delta);
await _timeWindowedCounter.IncrementAsync(counterId, delta);
_hyperLogLog.Add(userId);
await _aggregator.UpdateAggregateAsync(
counterId, $"shard:{result.ShardIndex}", delta);
return result;
}
public async Task<CountResult> GetCountAsync(string counterId)
{
var cached = _aggregator.GetCachedAggregate(counterId);
if (cached >= 0)
{
return new CountResult
{
CounterId = counterId,
Count = cached,
Accuracy = "exact",
ComputedAt = DateTime.UtcNow
};
}
return await _shardedCounter.GetCountWithMetaAsync(counterId);
}
public async Task<Dictionary<string, CountResult>> GetBatchCountsAsync(
IEnumerable<string> counterIds)
{
var tasks = counterIds
.Select(async id => (id, result: await GetCountAsync(id)));
var results = await Task.WhenAll(tasks);
return results.ToDictionary(r => r.id, r => r.result);
}
public async Task<TimeWindowedCountResult> GetCountInWindowAsync(
string counterId, int windowSeconds)
{
return await _timeWindowedCounter
.GetCountInWindowAsync(counterId, windowSeconds);
}
public long GetApproximateUniqueCount()
{
return _hyperLogLog.Count();
}
public async Task<long> GetApproximateUniqueCountForCounterAsync(
string counterId, string redisKey)
{
var db = ((ShardedCounter)typeof(ShardedCounter)
.GetField("_redis", System.Reflection.BindingFlags.NonPublic
| System.Reflection.BindingFlags.Instance)
?.GetValue(_shardedCounter) as IConnectionMultiplexer)
?.GetDatabase();
if (db == null) return 0;
return await db.HyperLogLogLengthAsync($"{counterId}:hll");
}
}
#endregion
#region Usage Example
public static class Program
{
public static async Task Main()
{
var redis = await ConnectionMultiplexer.ConnectAsync("localhost:6379");
var config = new CounterConfig
{
NumShards = 10,
ShardCacheTtl = TimeSpan.FromSeconds(1),
AggregateCacheTtl = TimeSpan.FromSeconds(5),
IdempotencyTtl = TimeSpan.FromHours(24)
};
var service = new CounterService(redis, config);
Console.WriteLine("=== Distributed Counter Demo ===\n");
// Simulate 1000 likes from 100 unique users
var random = new Random();
for (int i = 0; i < 1000; i++)
{
var userId = $"user_{random.Next(1, 101)}";
var result = await service.IncrementAsync(
"post:12345:likes",
userId,
delta: 1,
idempotencyKey: $"evt-{Guid.NewGuid()}");
if (i % 200 == 0)
{
Console.WriteLine(
$" After {i + 1} increments: " +
$"count={result.EstimatedCount}, " +
$"shard={result.ShardIndex}, " +
$"deduped={result.IsDeduped}");
}
}
var finalCount = await service.GetCountAsync("post:12345:likes");
Console.WriteLine($"\n Final exact count: {finalCount.Count}");
Console.WriteLine($" Approx unique users: " +
$"{service.GetApproximateUniqueCount()}");
var timeWindow = await service.GetCountInWindowAsync(
"post:12345:likes", 60);
Console.WriteLine($" Likes in last 60s: {timeWindow.Count}");
var batch = await service.GetBatchCountsAsync(new[]
{
"post:12345:likes",
"post:12345:comments",
"post:12345:shares"
});
Console.WriteLine("\n Batch counts:");
foreach (var (id, count) in batch)
Console.WriteLine($" {id}: {count.Count}");
}
}
#endregion
}
26. Distributed Counter for Real-Time Analytics Dashboards
Real-time analytics dashboards — the kind powering Grafana, Datadog, and CloudWatch — depend on distributed counters that can stream millions of data points per second while supporting instant, ad-hoc queries across arbitrary time windows. Unlike the like-button counter we designed earlier, analytics dashboards need multi-dimensional counters that can be sliced by time, region, endpoint, status code, and dozens of other tags — all while maintaining sub-second query latency.
Streaming Aggregation Architecture
The heart of an analytics dashboard is a streaming aggregation pipeline. Each metric event (e.g., "HTTP 200 on /api/users took 45ms in us-east-1") is consumed by a stream processor that pre-aggregates counters into multiple resolutions simultaneously. This is the same pattern used by Prometheus, M3, and Thanos for their time-series databases.
flowchart TB
subgraph Producers["Metric Producers"]
S1["App Server 1"]
S2["App Server 2"]
S3["App Server 3"]
end
subgraph Streaming["Stream Ingestion Layer"]
Kafka["Kafka (128 partitions)"]
Flink["Apache Flink Aggregator"]
end
subgraph Aggregation["Pre-Aggregation Pipeline"]
Raw["Raw Events (5s windows)"]
Min1["1-Minute Rollups"]
Min5["5-Minute Rollups"]
Hour1["1-Hour Rollups"]
Day1["1-Day Rollups"]
end
subgraph Storage["Time-Series Store"]
TSDB["M3DB / VictoriaMetrics"]
Hot["Hot: RAM (last 6h)"]
Warm["Warm: SSD (last 7d)"]
Cold["Cold: S3 (1 year+)"]
end
subgraph Dashboard["Query Layer"]
QAPI["Query API"]
Cache2["Aggregate Cache (Redis)"]
Downsample["Auto-Downsample"]
end
S1 & S2 & S3 --> Kafka
Kafka --> Flink
Flink --> Raw
Raw --> Min1 --> Min5
Min5 --> Hour1 --> Day1
Min1 & Min5 & Hour1 & Day1 --> TSDB
TSDB --> Hot & Warm & Cold
TSDB --> QAPI
QAPI --> Cache2
Cache2 --> Dashboard
Dashboard -->|"SELECT count WHERE region=us-east AND status=200"| QAPI
Time-Series Counter Data Model
An analytics counter is identified by a metric name and a set of label-value pairs. This is the Prometheus data model, which has become the industry standard for observability counters. Each unique combination of labels creates a separate time series, and the counter value is stored for every timestamp interval.
// Time-series counter data model for analytics dashboards
public class TimeSeriesCounter
{
private readonly IDatabase _redis;
private readonly ConcurrentDictionary<string, long> _localCache = new();
private readonly Timer _flushTimer;
public TimeSeriesCounter(IConnectionMultiplexer redis)
{
_redis = redis.GetDatabase();
_flushTimer = new Timer(async _ => await FlushLocalCacheAsync(),
null, TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(1));
}
// Record a metric point with tags
// Example: RecordMetric("http_requests_total", 1,
// ("method", "GET"), ("path", "/api/users"), ("status", "200"))
public async Task RecordMetricAsync(
string metricName,
long value,
params (string Key, string Value)[] tags)
{
var seriesId = BuildSeriesId(metricName, tags);
var now = DateTimeOffset.UtcNow;
var bucketKey = $"{seriesId}:{now.ToUnixTimeSeconds()}";
// Local cache for sub-ms writes
_localCache.AddOrUpdate(bucketKey, value, (_, v) => v + value);
// Write-through to Redis with TTL
await _redis.StringIncrementAsync(bucketKey, value);
await _redis.KeyExpireAsync(bucketKey, TimeSpan.FromHours(6));
}
// Query count across a time range with label filters
// Example: QueryCount("http_requests_total",
// DateTime.UtcNow.AddHours(-1), DateTime.UtcNow,
// ("status", "200"), ("path", "/api/users"))
public async Task<long> QueryCountAsync(
string metricName,
DateTime from,
DateTime to,
params (string Key, string Value)[] tags)
{
var seriesId = BuildSeriesId(metricName, tags);
var db = _redis.GetDatabase();
var tasks = new List<Task<RedisValue>>();
for (var t = from; t <= to; t = t.AddSeconds(1))
{
var key = $"{seriesId}:{new DateTimeOffset(t).ToUnixTimeSeconds()}";
tasks.Add(db.StringGetAsync(key));
}
var values = await Task.WhenAll(tasks);
return values.Where(v => v.HasValue).Sum(v => (long)v);
}
// Support wildcard label matching for dashboard queries
// e.g., sum all status codes for a given path
public async Task<long> QueryAggregatedAsync(
string metricName,
DateTime from,
DateTime to,
params (string Key, string Value)[] matchTags)
{
// Expand wildcard labels using Redis SCAN or a label index
var matchedSeries = await ExpandLabelWildcardsAsync(
metricName, matchTags);
var total = 0L;
foreach (var seriesId in matchedSeries)
{
total += await QueryCountAsync(metricName, from, to,
ParseTagsFromSeriesId(seriesId));
}
return total;
}
private static string BuildSeriesId(
string metricName, params (string Key, string Value)[] tags)
{
var sb = new StringBuilder();
sb.Append("metric:").Append(metricName);
// Sort tags for consistent series IDs
var sorted = tags.OrderBy(t => t.Key).ToArray();
foreach (var (key, value) in sorted)
{
sb.Append(':').Append(key).Append('=').Append(value);
}
return sb.ToString();
}
private async Task<List<string>> ExpandLabelWildcardsAsync(
string metricName, (string Key, string Value)[] matchTags)
{
// Simplified: in production this would use a label index
// (inverted index of label values -> series IDs)
await Task.CompletedTask;
return new List<string>();
}
private static (string Key, string Value)[] ParseTagsFromSeriesId(
string seriesId)
{
var parts = seriesId.Split(':').Skip(1).ToArray();
return parts.Select(p =>
{
var eq = p.IndexOf('=');
return eq >= 0
? (p[..eq], p[(eq + 1)..])
: (p, "");
}).ToArray();
}
private async Task FlushLocalCacheAsync()
{
var snapshot = Interlocked.Exchange(
ref _localCache,
new ConcurrentDictionary<string, long>());
foreach (var (key, value) in snapshot)
{
if (value != 0)
{
await _redis.StringIncrementAsync(key, value);
}
}
}
}
Downsampling Strategies for Dashboard Performance
When a dashboard query covers a wide time range (e.g., "last 30 days"), querying raw second-resolution counters would be prohibitively expensive. Downsampling is the answer: pre-compute reduced-resolution summaries from the raw data. The key insight is that chart rendering rarely needs more than a few hundred data points — a 30-day chart with 1-minute resolution needs only 43,200 points, while the raw 1-second data would need 2.6 million.
| Time Range | Raw Resolution | Downsample Resolution | Data Points | Query Time |
|---|---|---|---|---|
| Last 1 hour | 5 seconds | 5 seconds | 720 | <5ms |
| Last 6 hours | 5 seconds | 1 minute | 360 | <10ms |
| Last 24 hours | 5 seconds | 5 minutes | 288 | <20ms |
| Last 7 days | 5 seconds | 30 minutes | 336 | <50ms |
| Last 30 days | 5 seconds | 2 hours | 360 | <100ms |
| Last 1 year | 5 seconds | 1 day | 365 | <200ms |
Downsampling is typically done with a ladder of rollups: the stream processor maintains multiple aggregation tables at different resolutions. When a query arrives, the query router selects the appropriate resolution based on the time range. If the resolution is too coarse, the router can fall back to the next finer resolution and auto-downsample on-the-fly.
Grafana-Style Query Pattern
Grafana-style dashboards issue range queries of the form: rate(http_requests_total[5m]) or sum by (status) (http_requests_total{path="/api/users"}). These query patterns translate directly to the counter pre-aggregation pipeline: the rate() function divides the counter delta by the window duration, and the sum by operation aggregates across label dimensions. By pre-materializing common aggregations (per-status sums, per-path sums, per-region sums), the dashboard avoids scanning the full label space on every query.
The distributed counter for analytics dashboards is fundamentally a write-optimized, pre-aggregated, multi-resolution time-series database. Every metric event is written once and immediately visible in the hot store. Background rollup jobs continuously materialize lower-resolution summaries. The query layer automatically selects the best resolution, and the cache layer absorbs the dashboard's frequent refresh cycles. This architecture — streaming ingestion, multi-resolution rollups, and a query-aware cache — is what powers every major observability platform at scale.
27. Counter Consistency Models Compared
Throughout this guide, we have advocated for eventual consistency as the default choice for distributed counters. But "eventual consistency" is not a single model — it is a family of models with distinct guarantees. Choosing the wrong consistency model for a given counter type (like counts vs. view counts vs. inventory counts vs. financial counters) can lead to incorrect behavior, user-facing anomalies, or even data loss. This section provides a rigorous comparison of the four major consistency models as they apply to distributed counters.
Model 1: Strong (Linearizable) Consistency
Under strong consistency, every read returns the most recent write. For a counter, this means that if User A increments the counter and gets a response, every subsequent read (by any user, anywhere) will see at least that value. This is the gold standard for correctness — it matches the mental model of a single-threaded program running on one machine.
Strong Consistency Cost Analysis
For a sharded counter with N=10, strong consistency requires either: (a) a distributed consensus protocol (Raft/Paxos) across all shards, adding 2-3 network round-trips per write, or (b) a single-writer lease that serializes all increments through one node. Both approaches reduce write throughput by 10-100x compared to eventual consistency. At Facebook-scale (1M writes/sec), strong consistency would require approximately $500K/month in infrastructure — roughly 50x the cost of our eventual consistency design.
Model 2: Eventual (Causal+) Consistency
Eventual consistency guarantees that if no new writes are made to a counter, all replicas will eventually converge to the same value. In practice, "eventually" means within 1-5 seconds. For counters, this is the default and correct model because increments are commutative and associative — order does not matter. If Region A adds 5 and Region B adds 3, the total is 8 regardless of which region's update arrives first at the global aggregator.
Model 3: Causal Consistency
Causal consistency ensures that causally related operations are seen in the correct order. If User A likes a post, and then User B replies to User A's comment about that post, any user reading the reply count must also see the like count that preceded it. This is stricter than eventual consistency but cheaper than strong consistency because it does not require global agreement — only causal dependency tracking via vector clocks or similar mechanisms.
Model 4: Read-Your-Writes (RYW) Consistency
RYW guarantees that after a user increments a counter, that user's subsequent reads will reflect their own write — but other users may still see the old value. This is the minimum viable consistency for social counters: when you like a post, you immediately see the updated count, but your friend on the other side of the world may not see it for another 2 seconds.
Consistency Model Decision Matrix
For like buttons and view counters: Read-Your-Writes is ideal — the user sees their own action immediately, which satisfies the psychological contract. For analytics dashboards: Eventual is sufficient — a 5-second stale dashboard is invisible to operators. For inventory counters (e.g., ticket availability): Strong is mandatory — double-selling a seat is a business disaster. For financial counters (e.g., payment counts): Causal ensures the audit trail is correct without paying for global linearizability.
Comparison Table
| Property | Strong | Eventual | Causal | Read-Your-Writes |
|---|---|---|---|---|
| Writes converge globally | Instantly | 1-5 seconds | 1-5 seconds | 1-5 seconds |
| User sees own writes | Always | Usually | Usually | Always |
| Causal ordering preserved | Always | No | Always | No |
| Write throughput (relative) | 1x | 100x | 50x | 80x |
| Read latency (P99) | 50-200ms | 1-5ms | 3-15ms | 1-5ms |
| Infrastructure cost (relative) | 50x | 1x | 2x | 1x |
| Implementation complexity | Very High | Low | Medium | Low |
| Fault tolerance | CP (sacrifices A) | AP (sacrifices C) | AP | AP |
| Best for counter type | Inventory, Financial | View counts, Analytics | Audit trails, Payments | Like buttons, Social |
Consistency Model Trade-Off Analysis by Counter Type
Like Buttons (Social Counters). The ideal consistency model is Read-Your-Writes. When a user likes a post, the psychological expectation is that the number they see immediately reflects their action. Other users may see a slightly stale count for a few seconds, which is imperceptible and acceptable. RYW requires only a simple session affinity or a local cache that is updated on write. The user's read is routed to the node that handled their write, or the write response includes the updated count that the client displays optimistically.
View Counters (YouTube, Medium). Eventual consistency is the right model. View counts are aggregated from millions of clients across every time zone. A user watching a video does not expect the view count to tick up instantly — they understand that view counts are approximate and updated periodically. In fact, YouTube deliberately batches view updates and shows "estimated views" with trailing digits (e.g., "1.2M views") to manage user expectations.
Inventory Counters (Ticket Sales, Flash Sales). Strong consistency is mandatory. If two users purchase the last two concert tickets simultaneously, the system must not oversell. This requires a distributed lock or a single-node atomic counter (like Redis INCR with a strict upper bound), combined with a compensating transaction if the count exceeds inventory. Some systems use a two-phase approach: a fast optimistic check with eventual consistency (show "10 remaining") and a strong consistency final check (actually reserve the seat with a database transaction).
Analytics Counters (Dashboard Metrics). Eventual consistency is the standard. A Grafana dashboard showing "requests per second" does not need exact values — it needs trending data within an acceptable margin. The 1-5 second staleness of eventual consistency is invisible on a graph that renders 1-minute data points. Moreover, the pre-aggregation pipeline naturally absorbs lag because it operates on tumbling windows: a 1-minute rollup is not finalized until 1 minute and 5 seconds have elapsed, providing a natural buffer for late-arriving events.
// Implementation of Read-Your-Writes consistency for social counters
public class ReadYourWritesCounter
{
private readonly IConnectionMultiplexer _redis;
private readonly ConcurrentDictionary<string, (long Count, DateTime Timestamp)> _userView = new();
private readonly TimeSpan _rywTtl = TimeSpan.FromSeconds(10);
public ReadYourWritesCounter(IConnectionMultiplexer redis)
{
_redis = redis;
}
public async Task<IncrementResult> IncrementWithRYWAsync(
string counterId, string userId, long delta = 1)
{
var db = _redis.GetDatabase();
var shard = Math.Abs(HashCode.Combine(counterId, userId)) % 10;
var shardKey = $"counter:{counterId}:shard:{shard}";
var newValue = await db.StringIncrementAsync(shardKey, delta);
// Store the user's personal view of the counter
var userKey = $"ryw:{userId}:{counterId}";
await db.StringSetAsync(userKey, newValue, _rywTtl);
// Update local dictionary for sub-millisecond subsequent reads
_userView[userKey] = ((long)newValue, DateTime.UtcNow);
// Get a fast approximate total
var keys = Enumerable.Range(0, 10)
.Select(i => (RedisKey)$"counter:{counterId}:shard:{i}")
.ToArray();
var values = await db.StringGetAsync(keys);
var total = values.Where(v => v.HasValue).Sum(v => (long)v);
return new IncrementResult
{
CounterId = counterId,
EstimatedCount = total,
ShardIndex = shard,
ServerTimestamp = DateTime.UtcNow
};
}
public async Task<long> GetCountWithRYWAsync(
string counterId, string userId)
{
var db = _redis.GetDatabase();
var userKey = $"ryw:{userId}:{counterId}";
// Step 1: Check user-specific cache
if (_userView.TryGetValue(userKey, out var cached) &&
(DateTime.UtcNow - cached.Timestamp) < _rywTtl)
{
return cached.Count; // User sees their own write instantly
}
// Step 2: Check Redis user view
var userValue = await db.StringGetAsync(userKey);
if (userValue.HasValue)
{
_userView[userKey] = ((long)userValue, DateTime.UtcNow);
return (long)userValue;
}
// Step 3: Fall back to global aggregate
var keys = Enumerable.Range(0, 10)
.Select(i => (RedisKey)$"counter:{counterId}:shard:{i}")
.ToArray();
var values = await db.StringGetAsync(keys);
return values.Where(v => v.HasValue).Sum(v => (long)v);
}
}
The choice of consistency model is the single most impactful architectural decision in a distributed counter system. Strong consistency buys correctness at enormous cost; eventual consistency buys performance at the cost of bounded staleness. Between them lies a spectrum of models — causal consistency for audit trails, read-your-writes for social features, session consistency for user-facing dashboards. The senior engineer's job is not to pick the strongest model but to pick the weakest model that satisfies the business requirements, because every increment of consistency strength reduces throughput, increases latency, and increases infrastructure cost.
The Consistency-Cost Curve
In distributed counter systems, consistency is not binary. The cost of moving from eventual to causal consistency is roughly 2x infrastructure. Moving from causal to strong is another 25x. The knee of this curve is between eventual and causal — that is where most practical counter systems should operate. Only financial and inventory counters justify the cost of strong consistency, and even then, only for a limited subset of operations (the final seat reservation, the final payment settlement).
28. Conclusion
Designing a distributed counter system that operates at billion-QPS scale is far more nuanced than it appears on the surface. The seemingly simple operation of "increment a number" explodes into a constellation of challenges when you must handle Facebook-scale traffic while maintaining sub-millisecond latency, eventual consistency, fault tolerance, and cost efficiency.
The key architectural decisions we covered:
- Sharding eliminates hot partitions by distributing a single counter's writes across N shards, typically 10-20 for like buttons.
- Multi-tier caching (L1 in-process, L2 Redis, L3 database) reduces read latency to sub-millisecond for hot counters while serving 99%+ of reads from cache.
- Write-behind with coalescing batches increments in memory and flushes aggregated deltas to durable storage, reducing write amplification by 10,000x.
- HyperLogLog provides approximate unique counts in fixed 12 KB memory, enabling unique user tracking at scale without linear memory growth.
- Time-windowed buckets enable "likes in the last 5 minutes" queries with O(1) bucket lookups and automatic TTL cleanup.
- Idempotency-based deduplication with a Bloom filter + Redis two-layer approach achieves effectively exactly-once increment semantics.
- Cross-region replication leverages the commutative property of increments for simple, conflict-free global aggregation.
The distributed counter is a microcosm of distributed systems engineering. Every technique used here — sharding, caching, write-behind queues, probabilistic data structures, event streaming, eventual consistency — appears in larger, more complex systems. Mastering the counter problem prepares you for designing any high-throughput, low-latency distributed system.
Whether you are preparing for a senior engineering interview or building the next Facebook-scale engagement system, the patterns in this guide will serve as your foundation. The counter may be the simplest data structure, but a distributed counter is one of the richest design problems in computer science.
Key Takeaways for Interviews
- Always lead with capacity estimation — it drives every subsequent design decision.
- Sharding is the first and most important optimization for write-heavy counters.
- Eventual consistency is the correct default for counters — push back on strong consistency unless there is a concrete business requirement.
- Approximate counting (HyperLogLog, Count-Min Sketch) is not a compromise — it is often the superior engineering choice.
- The write path (increment) and read path (get count) have very different optimization strategies. Optimize them independently.