Vibe Engines
YouTube
System Design

Design a Distributed Counter

Step 1 / 9

Learn system design by building a distributed counter for view counts, likes and rate metrics at massive scale, step by step.

The numbers to beatN shardsper hot keyrandom shardon incrementsum Non read

The whole design, in writing

Learn system design by building a distributed counter for view counts, likes and rate metrics at massive scale, step by step. An interactive guide covering why a single UPDATE row melts under write contention, sharded sub-counters that trade read cost for write throughput, batching through a stream, caching the total, HyperLogLog for cheap unique counts, idempotent exactly-once increments, and the unhappy paths (hot keys, races, time windows).

Every step of the build above, written out: the problem each piece solves, the option that was taken and the ones that were not, the numbers, and how it fails in production.

The big idea

Why is counting hard?

"Show the view count" seems like the easiest feature ever written. But a viral video gets hundreds of thousands of views per second, and they all want to increment the same number. Every increment is a write to one value — and everyone's fighting over it at once.

Event Sourcesviews · likes
New in this step: Event Sources.

A distributed counter makes a single logical count absorb a massive write rate while staying cheap to read. The core trick is to stop treating it as one number: shard it into many sub-counters, spread writes across them, and sum on read. Add batching, caching, and (for uniques) probabilistic sketches, and you can count anything at any scale.

What the new pieces do

Event Sourcesclient
A firehose of increment events — every video view, every like, every request to rate-limit — from millions of users.

Step 1 · One row, one bottleneck

UPDATE n = n + 1 melts

The textbook version: a row (key, count), and each event runs UPDATE counter SET count = count + 1 WHERE key = ?. Correct. So why does a hot key destroy it?

Event Sourcesviews · likes
The system as it stands at this step.

One row, UPDATE count = count + 1 per event, on a viral key. What breaks?

  1. A 64-bit integer counts to billions of billions — magnitude isn't the issue. The problem is contention: every increment must lock and serialize on the same row.

  2. Reading one value is trivial. The bottleneck is writes: all increments target the same row, so they serialize on its lock and can't go faster than one-at-a-time round trips.

  3. A read-modify-write on one row must take a lock; under hundreds of thousands of concurrent increments they queue behind each other, throughput collapses, and latency spikes. The single row is a write hotspot.

Every increment is a read-modify-write on the same row, so it must take a lock. Under a viral key, hundreds of thousands of increments per second all serialize behind that one lock — throughput collapses and latency explodes. The number isn't the problem; the single point of contention is. You must spread the writes.

Why this piece earns its place

The tempting response is to buy a bigger database, and it is worth being precise about why that fails. The ceiling here is not CPU or disk, it is the time the row's lock is held, times one. If a single increment holds that row for a network round trip plus a durable write, the system does one increment per that interval no matter how many cores sit behind it, because every writer is queued on the same cell. More hardware moves a number that is not the bottleneck. The degradation is not gentle either: as arrivals approach that serialized service rate the queue runs away, so a key does not get slower in proportion to its popularity, it falls off a cliff somewhere in the middle of the launch. And the damage rarely stays local — increments waiting on the lock hold connections out of a shared pool, so one viral key pushes latency onto unrelated queries that never touch it. That blast radius is why this gets fixed structurally instead of tuned around.

Step 2 · A service in front

Own the counting logic

Before sharding, put a counter service between the event sources and storage so the increment/read logic lives in one place. But the service alone doesn't fix contention — what has to change behind it?

Event Sourcesviews · likesCounter Serviceincr · read
New in this step: Counter Service.

Add a counter service that owns two operations: increment(key) and read(key). It hides how the count is stored, so you can change the storage strategy (sharding, batching, caching) without touching callers. The service is the seam; the next steps make what's behind it scale. The key realization: you can trade a little read cost and a little freshness for enormous write throughput.

Why this piece earns its place

A seam only buys freedom if its interface stops promising things the implementations behind it cannot keep, and there is one specific mistake here that closes every door in the steps that follow: having increment return the new total. It is the obvious signature, callers love it, and it quietly requires the write path to stay synchronous, unsharded and unbatched forever — you cannot answer it from one random sub-counter, and you cannot answer it at all while the increment is still sitting in a buffer. So increment acknowledges and returns nothing, and read is a separate call that advertises what it really is: a recent value, not a transactional one. The same goes for a caller that increments and immediately reads expecting to see itself. That is the one thing this design will never do, so it belongs in the contract on day one rather than being discovered later by a feature team. Deciding what the API is allowed to promise is the work of this step; the box on the diagram is the easy part.

What the new pieces do

Counter Servicegateway
Applies increments to a shard and answers reads. Never a single row — it spreads writes across many sub-counters.

Step 3 · Shard the counter

Many sub-counters, summed

One row can't take the write rate. How do you let thousands of increments to the same logical key happen in parallel without them fighting over one value?

Counter ServiceshardedShard Asub-counterShard Bsub-counterShard Csub-counter
New in this step: Shard A, Shard B, Shard C.

How do you let many increments to ONE key run in parallel?

  1. Instead of one row, keep N sub-counters for the key. Each increment picks a shard (random or by writer), so writes spread across N locks and run in parallel; a read sums the N shards. You trade an N-way sum on read for N× write throughput.

  2. Optimistic retries still all target one row, so under high contention they mostly collide and retry — throughput still collapses. You must physically spread writes across multiple cells, not retry the same one.

  3. Serializing through a queue preserves the single-writer bottleneck — you've just moved the queue. Sharding lets increments truly run in parallel across independent cells.

Shard the counter: keep N sub-counters for each key (e.g. views:vid42:#0..#15). Each increment picks a shard — at random or by the writer's id — so writes spread across N independent cells and run in parallel, N× the throughput of one row. A read sums the N shards. You've traded a cheap N-way sum on read for a massive gain in write concurrency.

Why this piece earns its place

N is the knob, and it should not be one number for the whole system. Too low and the contention from the last step comes back divided — each cell is still a lock, and a key hot enough to melt one row will melt sixteen. Too high costs you somewhere easy to miss: N is paid per key, not per hot key, so a uniform shard count multiplies the cells of an enormous long tail of keys that get read once and incremented twice. Most keys are cold. So I would set N by heat, starting small and promoting a key as it warms, which is also why the shard count has to be recorded with the key rather than compiled into the reader. The day you raise N, reads must keep summing the cells the old writers used — a read that only covers the new range silently drops every increment that landed before the change, and that reads as a product bug, not a counter bug. Growing N is safe when reads sum a superset; shrinking it is the operation that needs a deliberate merge.

  • N shardsper hot key
  • random shardon increment
  • sum Non read

What the new pieces do

Shard Acache
One of N sub-counters for a key. An increment hits a random shard, so no single row is a write hotspot.
Shard Bcache
Another sub-counter. Writes spread across shards; the true count is the sum of all of them.
Shard Ccache
More shards = more write throughput for a hot key, at the cost of summing more shards on read.

Step 4 · Don't write per event

Batch through a stream

Even sharded, hitting the store on every single event is a lot of tiny writes. Many events can be combined: 500 increments in a 100ms window are just +500. How do you collapse them?

Event Streambatch · dedupeCounter Servicesharded
New in this step: Event Stream.

Sharded, but still one store write per event. How do you cut the write rate further?

  1. Batching doesn't lose data if the buffer is durable (a stream/queue); it just combines many +1s into one +N. Per-event writes are wasteful when you can aggregate first.

  2. You can approximate uniques (step 6), but for a total view count you want to actually add every event — just aggregated, not dropped. Batch, don't discard.

  3. Events go onto a durable stream (Kafka); consumers aggregate counts per key in memory over a short window and flush a single +N to the shard. The store sees far fewer, larger writes, and the stream gives durability + replay + dedupe.

Route increments through a durable stream and aggregate before writing. Consumers (or the app itself) sum increments per key in memory over a short window and flush a single +N to a shard instead of N separate +1s. The store sees far fewer, larger writes; the stream adds durability, replay, and a natural place to deduplicate. Local pre-aggregation + sharding together tame almost any write rate.

Why this piece earns its place

Aggregating in memory makes the consumer stateful, and that changes the shape of failure. The partial sum held between flushes exists in exactly one place, so a crash or a partition rebalance drops it — which is why the stream offset may only advance after a flush is acknowledged, never before. That ordering converts every loss into a possible double count instead, and duplicates are the failure I would rather have, because the durability step later gives me a way to collapse them. The subtler cost is that the count now sits downstream of a queue, and queues fall behind. A lagging consumer does not raise an error or return something that looks stale; it returns a smaller number, which is indistinguishable from a quieter hour. Nobody files a ticket about a view count that looks perfectly reasonable and is wrong. So consumer lag is the health signal for this whole path, watched directly rather than inferred from the counts, and the flush window is sized against how far behind the product can afford to be, not against how much write volume I would like to save.

  • +Nnot N × +1
  • streamdurable + replayable
  • windowaggregate before flush

What the new pieces do

Event Streambus
Buffers the increment firehose so it can be batched and deduplicated before touching the counters.

Step 5 · Cheap reads

Cache the total

Reads now sum N shards, and popular counts are read constantly (every viewer sees the number). Summing shards on every read re-introduces load. How do you make reads O(1)?

Counter ServiceShard AShard BShard CCached Total
New in this step: Cached Total. · swipe to pan the diagram

Cache the summed total. A background job periodically sums the shards and writes the grand total to a single cached value that reads return directly — O(1), one lookup. The cache is refreshed every second or few, so the displayed count lags reality by only that window (invisible for view/like counts). Reads hit the cache; writes hit the shards; the two are decoupled.

Why this piece earns its place

Two things go wrong with a number published by a background job. The first is that the job can die while reads stay perfectly healthy: the cache still answers, the count simply stops moving, and a frozen counter looks exactly like a counter nobody is incrementing. So the total is stored alongside the time it was computed, and a reader that finds it older than the refresh window treats it as absent and sums the shards live — slower, but moving. The second is that the published total can genuinely move backwards: two refresh runs can overlap and publish out of order, and a shard that is lost comes back short until the durable rollup rebuilds it. Nobody notices a view count a second behind; everybody notices one that counts backwards, and it reads as data loss long after the shards underneath have caught up. For a quantity that only ever increases I would never publish a total below the one already published, and treat a drop as something to investigate rather than a value to show.

  • O(1)cached read
  • ~1sstaleness
  • decoupledwrite path ≠ read path

What the new pieces do

Cached Totaldata
The periodically-summed grand total, cached so a read returns one number instead of summing every shard.

Step 6 · Counting *unique* things

HyperLogLog for cardinality

"Unique viewers" is a different, harder count: you must not double-count the same user. Storing every viewer's id to dedupe would cost gigabytes per popular video. How do you count distinct items cheaply?

Counter ServiceshardedShard Csub-counterHyperLogLogunique count
New in this step: HyperLogLog.

Count UNIQUE viewers without storing every id. How?

  1. HyperLogLog estimates the number of distinct items from the pattern of hash values, using a tiny fixed amount of memory (a few KB) regardless of cardinality, with ~2% error. Perfect for "unique views/visitors" where exactness isn't required.

  2. An exact set of ids costs memory proportional to the number of uniques — gigabytes for a viral video, and it grows without bound. When approximate is acceptable, a sketch is thousands of times smaller.

  3. Dividing by a guessed average is not a real distinct count and is wildly inaccurate. HyperLogLog gives a principled, bounded-error estimate in tiny memory.

Use a HyperLogLog sketch. It estimates cardinality (distinct count) from the bit-patterns of hashed items in a fixed few kilobytes — no matter if there are a thousand uniques or a billion — with ~2% error. Add an item and it may update the sketch; ask for the count and it estimates. It even merges (union two sketches to combine days/regions). For "unique viewers/visitors," it replaces gigabytes of ids with a tiny, mergeable summary.

Why this piece earns its place

The sketch answers exactly one shape of question, and it is worth naming that before product asks for another. Sketches merge, so any union is cheap — this video plus that one, today plus yesterday, one region plus another. Nothing else is. You cannot subtract one sketch from another to get "people who watched last week but not this week", and you cannot intersect two to get an overlap; the arithmetic that looks available on the numbers is not available on the sketches, and doing it on the estimates compounds the error of both. So I would keep sketches at the finest granularity anyone will ever slice by and merge upward, because merging is the only operation I get. The other thing frozen on day one is the hash: the estimate is a property of one hash function and precision, so changing either makes new sketches unmergeable with every sketch already stored. That is a migration, not a config change. And the error is relative, which puts the uncertainty where the numbers are biggest: two percent of a thousand uniques is nothing, two percent of a billion is a gap wider than most counts ever reach. So the sketch is the right answer for a dashboard and the wrong one for anything that gets reconciled against an exact report.

  • ~2%error
  • few KBfixed memory
  • mergeableunion sketches

What the new pieces do

HyperLogLogservice
A probabilistic sketch that counts *distinct* items (unique viewers) in tiny fixed memory, with ~2% error.

Step 7 · Durability & exactly-once

Don't lose (or double) counts

In-memory shards are fast but volatile, and a retried request could increment twice. For view counts a tiny drift is fine — but you still want the count durable and, where it matters, exactly-once.

Counter ServiceShard BShard CDurable Rollup
New in this step: Durable Rollup. · swipe to pan the diagram

Flush shards to a durable rollup (the source of truth) so a lost shard is rebuilt, not lost. For exactly-once increments, attach an idempotency key (event id) and dedupe it — at the stream (offset/exactly-once semantics) or with a seen-set — so a retry adds nothing. Decide per use case: view/like counts tolerate at-least-once and slight drift; billing or inventory counts demand exactly-once and strong durability.

Why this piece earns its place

Exactly-once is not a flag, it is a second stateful system, and it is worth pricing before promising it. Deduplicating by event id means remembering ids, and remembering every id forever is the unbounded set this whole design has been avoiding. So in practice you remember them for a window, and that window is the guarantee: a retry inside it is free, a retry outside it counts twice. That matters more than it sounds, because the slowest retries come from clients that were offline and are now draining a queue from hours ago — the case where a duplicate is most likely is the case a short window misses. The seen-set also takes the same traffic the counter does, so it is hot in its own right, and needs its own sharding and durability before it is worth anything. All of which is the real argument for deciding this per use case: for views and likes, drift is cheaper than the machine that removes it; for anything denominated in money, the machine is cheaper than being wrong.

  • rollupdurable truth
  • idempotency keydedupe retries
  • per use caseapprox vs exact

What the new pieces do

Durable Rollupdata
The durable record the shards flush into — survives restarts and lets lost shards be rebuilt.

Step 8 · The sharp edges

Hot keys, races & time windows

Reality: one key goes supernova (a hotter hot key), naive read-modify-write still races, and product wants "views in the last hour," not just all-time.

Event SourcesEvent StreamCounter ServiceShard AShard BShard CHyperLogLogCached TotalDurable Rollup
The system as it stands at this step. · swipe to pan the diagram

For an extreme hot key, increase its shard count dynamically and add an edge/CDN counter that pre-aggregates before the service even sees it. Avoid races by using atomic increments (INCRBY) or the stream-aggregate path — never a manual read-then-write. Serve time-windowed counts with per-bucket counters (per-minute/hour sub-counters you sum over the window) or a decaying counter. And pick consistency deliberately: eventual + approximate for scale, strong + exact only where correctness is money.

Why this piece earns its place

Windowed counts raise a question the all-time count never had to answer: whose clock decides which bucket an event belongs to. A client timestamp is attacker-controlled and wrong on a surprising number of real devices, so the bucket gets assigned at ingest, on a server. But with a stream in front of the counters, ingest is not arrival — an event can land after the bucket it belongs to has already been read and shown to someone. So "views in the last hour" has to be defined against a watermark: buckets stay open for a bounded lateness, close after it, and anything later is either dropped or booked to the current bucket deliberately rather than by accident. Say which, because the two give different numbers for the same traffic. The other cost is cardinality — per-minute buckets multiply cells by however many buckets you retain, for every key — so fine buckets get a short TTL and are rolled up into coarse ones as they age. Otherwise the windowed feature quietly becomes the largest thing in the store.

You did it

You just designed a distributed counter.

Event SourcesEvent StreamCounter ServiceShard AShard BShard CHyperLogLogCached TotalDurable Rollup
The finished design, end to end. · swipe to pan the diagram

Everything you assembled, in order

  • A single UPDATE row serializes every increment on one lock — a hot key melts it.
  • A counter service lets you trade a little read cost and freshness for huge write throughput.
  • Shard into N sub-counters: increment a random shard (parallel writes), sum N on read.
  • Batch increments through a durable stream — flush +N instead of N × +1 — to cut write volume.
  • Cache the summed total so reads are O(1), lagging reality by only a second or two.
  • Count uniques with HyperLogLog: ~2% error in a few KB, mergeable — no storing ids.
  • Flush to a durable rollup and use idempotency keys; choose approximate-vs-exact per use case.

Where an interviewer pokes next

Getting the boxes right is the easy half. These are the questions that separate a candidate who drew the diagram from one who has run the thing. Answer each one out loud before you open it.

  1. Why does a single-row counter fail, when the number itself is trivial to store?

    The difficulty has nothing to do with the magnitude of the number — a 64-bit integer counts astronomically high. It's about write contention. Every increment is a read-modify-write against the same row, which the database must serialize with a lock (or, with optimistic concurrency, a compare-and-set that mostly conflicts and retries under load). When a key is cold that's invisible, but when a key goes viral and takes hundreds of thousands of increments per second, all of those writes target the identical cell and are forced into a single-file queue behind its lock; throughput plateaus at roughly one-write-at-a-time round trips and latency balloons. The bottleneck is the single shared point that every writer must touch, so the fix is structural: physically spread the writes across many independent cells so they stop fighting over one lock.

  2. How does sharding a counter trade read cost for write throughput?

    Instead of one row per logical counter, you keep N sub-counters for the key (views:vid42:#0 through #15, say). Each increment picks one shard — at random, or deterministically by the writer's id — and increments only that cell, so N writers can proceed in parallel against N different locks instead of serializing on one, giving up to N times the write throughput. The logical count is simply the sum of all N sub-counters, so a read now costs an N-way sum instead of a single lookup. That's the explicit trade: you've made writes N times more parallel at the cost of making reads do N times more work. Because counters are overwhelmingly write-heavy and reads can tolerate a tiny bit of staleness, that's a great deal — and you claw the read cost back by caching the summed total so reads become O(1) again, with only a second or two of lag.

  3. When would you use HyperLogLog instead of just counting?

    You use HyperLogLog when you need to count distinct items — cardinality — rather than total events, and exactness isn't required. Counting total views is just addition, but counting unique viewers means you must not double-count the same user, which naively requires remembering every id you've seen in a set; for a viral video that set costs gigabytes and grows without bound. HyperLogLog is a probabilistic sketch that estimates the number of distinct items from the statistical pattern of hashed values (roughly: the more distinct things you've hashed, the more likely you are to have observed a rare pattern like a long run of leading zeros), and it does so in a fixed few kilobytes regardless of whether there are a thousand or a billion uniques, with about 2% error. It's also mergeable — you can union two sketches to combine, say, per-day or per-region counts into a total — which makes windowed and segmented unique counts cheap. So: exact total counts use sharded adders; approximate unique counts use HyperLogLog.

  4. Why is caching the total safe when it makes the displayed count stale?

    Because of the specific consistency needs of the things counters usually count. A view count, like count, or reaction total is a vanity/engagement metric where no user notices or cares whether the number is a second or two behind the true value, or momentarily off by a handful — the product requirement is "roughly right and always fast," not "exact to the instant." Given that tolerance, you let the write path stay sharded and fast, and have a background job periodically sum the shards into a single cached total that reads return in O(1). The only cost is that the number lags reality by the refresh interval, which is invisible for these use cases. The decoupling is the point: writes scale on the shards, reads scale on the cached total, and neither path bottlenecks the other. For counts where staleness or drift is unacceptable — money, inventory, billing — you'd instead demand strong consistency and exact durability, accepting the lower write ceiling that comes with it.

  5. How do you make an increment exactly-once when "add 1" isn't idempotent?

    Adding one is inherently not idempotent: if a client retries a request that already succeeded (because the response was lost), a naive counter adds again and over-counts. You make it idempotent by giving every increment a unique idempotency key — typically the event id — and ensuring each key is applied at most once. Concretely, you dedupe: route increments through a durable stream and rely on its exactly-once/offset semantics, or maintain a seen-set of recently applied event ids and ignore any id you've already counted, or use a conditional write keyed on the event id so a duplicate is a no-op. How rigorously you enforce this is a deliberate business decision tied to the earlier durability call: for vanity metrics like views and likes you can accept at-least-once semantics and the resulting tiny drift, keeping the pipeline cheap and fast; for anything where each count is money or inventory you invest in strict exactly-once processing plus a durable source of truth, trading throughput for correctness.

Check yourself — the answers, and why

Nine steps in, these are the calls you should be able to make cold. Pick one, then read why.

  1. A single-row counter melts under a hot key because…

    Read-modify-write on one cell forces all writers into a single-file queue.

  2. A sharded counter works by…

    Writes spread across N cells (parallel); the total is their sum — reads cost more, writes scale.

  3. Batching increments through a stream lets you…

    Pre-aggregate over a window; the store sees far fewer, larger, replayable writes.

  4. Reads become O(1) again by…

    A background sum feeds a cached grand total; reads return one number, ~1s behind.

  5. HyperLogLog is used to…

    Cardinality estimation in fixed memory, mergeable — no gigabytes of ids for unique counts.

How you’d open this design in an interview

Before any boxes: agree what it must do, pin the qualities that shape everything, then build — naming each trade-off as you make it. The walkthrough above is that exact order.

What it must do

Agree on these before drawing a single box.

  • Increment: add to a logical count (a view, a like) millions of times a second.
  • Read the total: return the current count cheaply, tolerant of a second or two of staleness.
  • Count uniques: estimate distinct viewers without storing every id.
  • Windowed counts: serve "views in the last hour" from per-bucket counters.
  • Exactly-once where it matters: dedupe increments by idempotency key, with a durable rollup behind the shards.

The qualities that shape everything

Each one names the mechanism that buys it.

Change storage strategy without touching callers
A counter service owns increment/read and hides how the count is stored, so sharding, batching and caching can evolve behind a stable interface.
Absorb a viral write rate without a lock fight
The count is split into N sub-counters; each increment hits a random shard so writes spread across N locks and run in parallel — a read sums the N shards.
Collapse a firehose of tiny writes
Increments flow through a durable stream and are aggregated per key over a short window, flushing a single +N instead of N separate +1s.
O(1) reads despite N shards
A background job periodically sums the shards into one cached total that reads return directly, lagging reality by only a second or two.
Cheap unique counts
A HyperLogLog sketch estimates distinct viewers from hashed bit-patterns in a fixed few KB with ~2% error, instead of storing every id.
Durable, and exactly-once where it matters
Shards flush to a durable rollup so a lost shard is rebuilt, and an idempotency key dedupes retries wherever exactly-once is required.

The trade-offs you say out loud

Senior signal isn’t the boxes — it’s naming what you gave up and why it was the right price.

N sharded sub-counters over a single UPDATE row

A read-modify-write on one row takes a lock, so a viral key serializes hundreds of thousands of increments into a single-file queue — throughput collapses. Spreading writes across N independent cells lets them run in parallel; you trade an N-way sum on read for N× write throughput.

Aggregate through a durable stream (flush +N) over one store write per event

Per-event writes are wasteful when 500 increments in a window are just +500. A durable stream lets you batch many +1s into one +N without losing data, and adds replay and a natural place to deduplicate.

Cache the summed total over summing all shards on every read

Popular counts are read constantly, and summing N shards per read re-introduces the load sharding removed. A periodically-summed cached total makes reads O(1) at the cost of a second or two of staleness — invisible for view/like counts.

HyperLogLog for uniques over storing every viewer id in a set

An exact set of ids grows without bound — gigabytes for a viral video. A sketch estimates the distinct count in a fixed few KB with ~2% error, and even merges across days or regions.

The answer, out loud

What a strong answer to “Design a Distributed Counter” sounds like, first question to last trade-off. It is about 7 minutes of talking; the whiteboard and the interviewer fill the rest of the 45. Read it aloud once, then close the page and give it yourself.

  1. 0–4 min

    What we are actually counting

    Let me pin down what this is, because the name makes it sound trivial. We are counting things like views and likes: events from millions of users, hundreds of thousands per second on a key that goes viral, all of them wanting to move the same number. The read side is the easy half: people look constantly, but nobody can tell whether the number is a second old or off by a few. That asymmetry is the whole design. It is write-heavy and read-tolerant, so at every step I spend read work and freshness to buy write throughput.

  2. 4–9 min

    Why one row melts

    The textbook version is a single row, key and count, and an update that adds one. It is correct, and it is the thing that melts. Every increment is a read-modify-write against that same row, so it has to take a lock, and hundreds of thousands of them per second queue up single file behind it. The size of the number is never the issue; the issue is that every writer has to touch one cell. And this is not a tuning problem — no index or instance size makes a serialized queue parallel. The shape of the data has to change.

    Built in step 1: UPDATE n = n + 1 melts
  3. 9–14 min

    Put a service in front first

    So before I change the storage I would put a counter service in front of it, owning two operations: increment a key, and read a key. Not because the service fixes anything on its own, but because it is the seam. Everything I am about to do is a change to how the count is stored, and callers should never find out any of it happened. With that in place I can shard, batch and cache behind it without a migration on every team that increments something.

    Built in step 2: Own the counting logic
  4. 14–21 min

    Shard the counter

    The first real move is sharding. Instead of one row per logical count I keep N sub-counters for the key, and an increment picks one of them, at random or by the writer's id, and touches only that one. Writes now land on N independent cells with N independent locks and genuinely run in parallel, roughly N times the throughput of one row, and the logical count is just their sum. That is the trade stated plainly: writes became N times more parallel, reads became N times more work. On a workload this write-heavy that is a good deal, and I buy the read cost back shortly.

    Built in step 3: Many sub-counters, summed
  5. 21–28 min

    Stop writing once per event

    Sharded, I am still doing one store write per event, and a lot of them say the same thing — five hundred increments in a hundred milliseconds are just plus five hundred. So increments route through a durable stream and get aggregated before writing: consumers sum per key in memory over a short window and flush one plus-N instead of N separate plus-ones. The store sees far fewer, far larger writes. I would stress that the buffer is durable: this is aggregation, not sampling, every event still counts. And the stream earns its place twice, because it is also where you deduplicate and replay.

    Built in step 4: Batch through a stream
  6. 28–34 min

    Make reads one lookup again

    Now I pay the read cost back. A background job periodically sums the shards and writes one cached total, and reads return that — a single lookup, however many shards the key has. The number then trails reality by about the refresh interval, a second or two, which for a view count is invisible. What I like is that the write and read paths stop being the same system: writes scale on the shards, reads on the cached total. And if that cache disappears, reads get slower rather than wrong, because summing the shards live is still there underneath.

    Built in step 5: Cache the total
  7. 34–39 min

    Uniques are a different problem

    Unique viewers I would treat separately, because it is a different question. Total views is addition. Distinct people means not double-counting, which naively means remembering every id you have seen — gigabytes for a popular video, growing without bound. Instead I would use a HyperLogLog sketch, which estimates the distinct count from the pattern of hashed values in a fixed few kilobytes, whatever the true number is, at around two percent error. And it merges, so the union of two sketches gives the uniques of the combination — that is what makes per-day and per-region uniques affordable.

    Built in step 6: HyperLogLog for cardinality
  8. 39–43 min

    Durability, and when to pay for exactness

    Two loose ends. The shards are fast and volatile, so they flush into a durable rollup that is the source of truth — losing one costs the increments since its last flush, and it is rebuilt from there. And adding one is not idempotent, so a retried request counts twice; where that matters I attach an event id to each increment and refuse ids already applied. I would make that a per-use-case decision out loud, though. Views and likes can live with at-least-once and a little drift. Billing cannot.

    Built in step 7: Don't lose (or double) counts
  9. 43–45 min

    The trade, and what is left

    So the trade underneath all of it is one trade, made repeatedly: I have given up exactness and instant freshness and bought a write rate a single row could never reach. That is right for engagement metrics and wrong for money, and I would want the design reviewed against which of the two it counts. With more time I would go after the extreme hot key, pushing pre-aggregation out to the edge before the service ever sees the event, and windowed counts, which need per-bucket counters and a decision about which clock assigns the bucket.

What this teaches

Learn system design by building a distributed counter for view counts, likes and rate metrics at massive scale, step by step. An interactive guide covering why a single UPDATE row melts under write contention, sharded sub-counters that trade read cost for write throughput, batching through a stream, caching the total, HyperLogLog for cheap unique counts, idempotent exactly-once increments, and the unhappy paths (hot keys, races, time windows).

Key takeaways

  • A single UPDATE row serializes every increment on one lock — a hot key melts it.
  • A counter service lets you trade a little read cost and freshness for huge write throughput.
  • Shard into N sub-counters: increment a random shard (parallel writes), sum N on read.
  • Batch increments through a durable stream — flush +N instead of N × +1 — to cut write volume.
  • Cache the summed total so reads are O(1), lagging reality by only a second or two.
  • Count uniques with HyperLogLog: ~2% error in a few KB, mergeable — no storing ids.
  • Flush to a durable rollup and use idempotency keys; choose approximate-vs-exact per use case.

Concepts covered

  • Why is counting hard?
  • UPDATE n = n + 1 melts
  • Own the counting logic
  • Many sub-counters, summed
  • Batch through a stream
  • Cache the total
  • HyperLogLog for cardinality
  • Don't lose (or double) counts
  • Hot keys, races & time windows
built to count a billion clicks without a single lock fight — make the calls, drop a shard, run the gauntlet.
Finished this one? 0 / 65 System Designs done

Explore the topic

See this alongside everything else on the same subject — handbooks, system designs, challenges and tools, in one place.

More System Designs