← System design

Social & Feed

Design YouTube Likes Counter

A like button is one bit per person per video, and the number beside it is just the sum of those bits. The trouble is the shape of the traffic: a new music video can collect forty thousand likes a second, while the count beside it is read millions of times. You need a design where every person counts exactly once, a viral video cannot melt one database row, and the number on screen is close enough to right, quickly enough.
Core: like edges as truth, counts as batched deltas280K reactions a second at peak1.2M count reads a secondCounts settle within 10 s

01

What the counter must do

02

What the counter must do

Functional
  • A signed-in viewer can like or dislike a video, switch between the two, or clear their reaction.
  • Each viewer counts at most once per video, regardless of retries, double taps or several devices.
  • The watch page shows the like count, rounded for display ("48K", "1.2M").
  • A viewer always sees their own reaction reflected immediately, even before the public count catches up.
  • Creators see exact like and dislike totals in their dashboard; the public page shows likes only.
  • Reactions from accounts later found to be fake are removed and the counts corrected.
Non-Functional
  • Accept 280,000 reactions per second at peak, with a single video receiving up to 40,000 per second.
  • Serve 1.2 million count reads per second with p99 under 20 ms.
  • Public counts converge within about 10 seconds of a reaction; creator totals within a minute.
  • No lost reactions: once the button shows "liked", the like survives any single failure.
  • Counts may be briefly stale but must never drift permanently from the true number of reactions.
Questions that shape everything

Ask whether the public count must be exact or can be rounded, how stale it may be, and whether dislikes are shown. A rounded, slightly stale public number unlocks almost every simplification below. Also ask whether "who liked this" lists are needed. If they are, you need an index by video as well as by viewer.

02

Sizing reactions and reads

03

Sizing reactions and reads

Assume 6 billion reaction changes a day (likes, dislikes and undos), about 20 billion videos in the catalogue, and 30 billion watch-page loads a day that each read one count. Traffic peaks at about four times the daily average.

QuantityWorkingResult
Reaction changes per dayassumption6B
Average write rate6B / 86,400 sabout 70,000 / s
Peak write rate4x averageabout 280,000 / s
Hottest single videopremiere of a major music videoabout 40,000 / s for the first hour
Count reads per day30B watch pages + feed and search cardsabout 30B
Average and peak reads30B / 86,400 s, peak 3.5x350,000 / s and 1.2M / s
Stored like edges2 trillion active reactions x 40 Babout 80 TB, 240 TB with 3 replicas
Count rows20B videos x 32 B (likes, dislikes, watermark)about 640 GB
Hot count cache200M videos with recent views x about 60 Babout 12 GB per cache cluster
Kafka throughput at peak280K events x about 120 Babout 34 MB / s
The number that changes the design

280,000 writes a second across the whole platform is ordinary for a partitioned store. 40,000 writes a second to one counter row is not. Any design that increments a single row per like fails on exactly the videos that matter most, which is why the count is updated in batches instead.

Reads are easy

A count is a few bytes and tolerates seconds of staleness, so it caches extremely well. The read path is a cache lookup with a short TTL, and the database behind it sees a tiny fraction of the traffic.

03

The reaction API

04

The reaction API

POST/v1/videos/{video_id}/reactionSet the viewer's reaction
// Authorization: Bearer <viewer token> // The client sends the desired end state, never "+1", so retries are harmless { "reaction": "like" } // like | dislike | none // 200 OK { "reaction": "like", "changed": true, // false if it was already "like" "display_count": "48K" }
DELETE/v1/videos/{video_id}/reactionClear the reaction
// Same as setting reaction to "none". 200 whether or not a reaction existed. { "reaction": "none", "changed": true }
GET/v1/videos/{video_id}/statsPublic counts for a video
{ "video_id": "v_9Qk2LmT", "likes": 48213, "likes_display": "48K", "viewer_reaction": "like", // only when signed in, read from the viewer's own edge "as_of": "2026-10-01T11:02:41Z" } // Cache-Control: public, max-age=5 for the count; viewer_reaction is fetched separately
GET/v1/creator/videos/{video_id}/reactionsExact totals for the owner
{ "likes": 48213, "dislikes": 1904, "removed_as_spam_7d": 311 }
Idempotent by design

Because the client states the reaction it wants rather than an increment, a request retried five times after a flaky connection produces one change at most. The server works out the delta by comparing the new state with the old one.

04

Architecture overview

05

Architecture overview

Write path and read pathDiagram
flowchart LR
	V["Viewer app"] --> API["Reaction API"]
	API --> E[("Like edges, keyed by viewer")]
	E -. "change stream" .-> K[["Kafka: reaction deltas, keyed by video"]]
	K --> AG["Count aggregators"]
	AG --> C[("Count store")]
	AG -. "refresh" .-> RC[("Count cache")]
	W["Watch page"] --> SA["Stats API"]
	SA --> RC
	RC -->|miss| C
	SA --> E
	F["Fraud pipeline"] --> API
	R["Nightly recount job"] --> E
	R --> C

Keep two kinds of data and never confuse them. The like edge, one row per viewer and video saying like, dislike or none, is the source of truth. The count is a derived number that could be rebuilt from the edges at any time. Every component in the design follows from that split.

On the write path, the reaction API performs a single conditional write on the viewer's edge and learns the previous state in the same operation. If nothing changed, it returns immediately. If something did change, the change is captured from the edge table's change stream (or an outbox row written in the same transaction) and published to Kafka as a delta such as {video, likes: -1, dislikes: +1}, keyed by video ID.

Count aggregators consume those deltas, add them up in memory per video for about one second, and apply one combined update per video to the count store. A video receiving 40,000 likes a second therefore costs the count store one write a second, not forty thousand. After writing, the aggregator refreshes the cache entry so readers see the new value without waiting for a TTL.

On the read path, the stats API reads the count from a cache with a 5-second TTL and, for signed-in viewers, reads their own edge so the button shows the right state. A nightly job recounts edges for recently active videos and corrects any count that has drifted.

Like edges

A wide-column or key-value store partitioned by viewer ID, with the video ID as the row key inside the partition. Writes from one hot video spread across every partition because they come from different viewers.

Kafka: reaction deltas

Partitioned by video ID so all deltas for a video meet in one aggregator. Retains three days of events, which is the replay window for any aggregator bug.

Count aggregators

Stateless apart from one second of in-memory sums. Each owns a set of Kafka partitions and writes to the count store with a per-video offset watermark for safe replays.

Count store and cache

Count rows keyed by video in a replicated store; a Redis or Memcached layer in front holds counts for videos being watched now.

05

Deep dive: exactly once, and no hot rows

06

Deep dive: exactly once, and no hot rows

Two problems in one button

How do you make sure each viewer counts exactly once? And how do you keep one viral video from turning into a single overloaded row? The first is solved at the edge table, the second in the stream.

One tap, end to endDiagram
flowchart TD
	T["Viewer taps like"] --> CW["Conditional write on edge: none to like"]
	CW --> CH{"State changed?"}
	CH -->|no| OK1["Return current state"]
	CH -->|yes| D["Emit delta: likes +1"]
	D --> P["Kafka partition for this video"]
	P --> B["Aggregator sums deltas for 1 s"]
	B --> U["Apply +N to count row if offset is new"]
	U --> CR["Refresh count cache"]

Deduplication lives in the edge

Many designs put a Bloom filter or a Redis set in front of the counter to catch duplicates. That works, but it creates a second source of truth that can disagree with the first. Here the edge row itself is the dedupe record. The API reads and writes it in one atomic step, for example a lightweight transaction or a compare-and-set on a version, and the store tells it the previous value.

The delta then comes from the state transition, not from the request:

TransitionDelta emittedTypical cause
none to likelikes +1First like
like to nonelikes -1Undo
like to dislikelikes -1, dislikes +1Changed mind
dislike to likedislikes -1, likes +1Changed mind
like to likeno deltaRetry or double tap

Because the edge is partitioned by viewer, a viral video's likes land on thousands of different partitions. The hottest key on this table is a single person tapping repeatedly, which a per-viewer rate limit handles. Two devices racing for the same viewer are serialised by the version check; the loser re-reads and recomputes its delta.

Batching turns a hot row into a cool one

Deltas for one video always go to one Kafka partition and therefore one aggregator. The aggregator keeps a map from video ID to a pending delta and flushes every second, or sooner if the map grows past 50,000 entries. A flush writes each pending delta with one increment and records the highest Kafka offset it included:

# apply one batch for one video, safe to repeat UPDATE video_counts SET likes = likes + :d_likes, dislikes = dislikes + :d_dislikes, applied_offset = :batch_last_offset WHERE video_id = :video_id AND applied_offset < :batch_last_offset; # zero rows updated means this batch was already applied before a crash

If an aggregator crashes after writing counts but before committing its Kafka offsets, its replacement replays the same events, builds the same batches and finds each one already applied. That gives effectively exactly-once counting without distributed transactions, because only one aggregator writes any given video and its offsets increase monotonically.

A single Kafka partition comfortably handles tens of thousands of small messages a second, so even the hottest premiere fits in one partition. If a video ever went beyond that, the next step would be to split its key into a few sub-keys, aggregate each separately and add them up on read, which is the classic sharded counter. You keep that in reserve; it is rarely needed once batching is in place.

Reading counts

The count on the watch page comes from cache, with a 5-second TTL and a refresh pushed by the aggregator on every flush. Expiry is staggered with a little random jitter so that a cache full of hot videos does not expire in one instant. Display rounding hides most small inaccuracies: once a video passes 10,000 likes, the page shows "48K", and a count that is ten seconds behind looks exactly the same.

A viewer's own reaction is never read from the count. It is read from their edge, which was written synchronously, so the button is correct immediately. The client also adjusts the displayed number locally by one after a tap, which hides the few seconds before the aggregate catches up.

Counts drift, edges do not

Bugs, partial outages and fraud removals can all leave a count slightly wrong. A nightly job scans the edges of videos that had reactions in the last day, recounts them and repairs the count row. Since the edges are the truth, the count is always recoverable.

06

Decisions and their costs

07

Decisions and their costs

Chosen
Edge row as truth, count derived from deltas

Exact deduplication without extra structures, a count that can be rebuilt at any time, and an audit trail of who reacted. The price is storing 2 trillion edges.

Correct by construction
Rejected
Counter only, with a dedupe set beside it

Smaller storage and simple to start. But the set and the counter can disagree after a partial failure, and there is no way to recount or remove fake likes cleanly.

Drifts with no recovery
Chosen
Batch deltas per video in the stream

One write per video per second regardless of popularity, and the cost of a viral hit stays flat. Counts lag by about a second plus pipeline delay.

Handles hot videos
Alternative
Sharded counter rows, summed on read

Spreads one video's increments over N rows and works without a stream. Every read has to sum N rows, and N must be chosen per video, which is awkward to manage.

Kept in reserve
Chosen
Asynchronous count updates

The reaction API only touches one edge row, so its latency and availability do not depend on the count store at all. Readers see the new count a few seconds later.

Fast, isolated writes
Rejected
Update the count in the same request

Readers see the change immediately, but every like now writes the hottest row in the system synchronously, and a slow count store slows the like button.

Couples the hot path
Chosen
Cached counts with a short TTL and push refresh

Over 99% of reads never reach the store. Staleness is bounded by the flush interval and the TTL, both a few seconds.

Cheap and fresh enough
Rejected
Read the count store on every page view

Always current, but 1.2 million reads a second against a store that only needs to handle a few thousand writes, for no visible benefit.

Wasted capacity

07

Failure modes

08

Failure modes

🐌
Aggregators fall behind

A deploy goes wrong and the aggregator fleet restarts in a loop. Kafka keeps the deltas, but public counts freeze for twenty minutes during a big premiere.

Alert on consumer lag in seconds, not messages. Show cached counts as they are, since the viewer's own reaction still updates locally. Kafka retention of three days means nothing is lost while the fleet recovers.
🌪️
Cache stampede on a hot video

The cache entry for a trending video expires and thousands of concurrent readers miss together, all reading the same count row.

Refresh entries from the aggregator before they expire, add jitter to TTLs, and use request coalescing so one miss fetches while the others wait for its result.
🔀
Two devices race for one viewer

A viewer taps like on a phone and dislike on a laptop within the same second. Without care, both deltas apply and the counts end up off by one in each column.

The edge write is conditional on version. One write wins, the other re-reads the new state and computes its delta from there, so exactly one transition is counted per change.
💀
An aggregator dies mid-batch

The aggregator has written counts for half its videos and has not committed offsets when its host disappears.

The replacement replays from the last committed offset. Videos already written are skipped by the offset watermark; the rest are applied once. No count is doubled.
🤖
A bot farm likes a video

Fifty thousand fake accounts like one video in an hour. The fraud system flags them a day later.

The fraud pipeline sets those edges back to none through the normal API path, which emits negative deltas. Counts correct themselves, and creator dashboards show how many reactions were removed.
📉
Silent drift after a bug

A release emits deltas with the wrong sign for undos on one client version. Counts slowly inflate for a week before anyone notices.

The nightly recount repairs every recently active video from edges and reports how many counts it changed. A spike in repairs is itself an alert.

08

Designs to avoid

⚠

Designs to avoid

🚫
Incrementing one database row on every like

The row for a viral video becomes the busiest key in the company. Lock contention or hot-partition throttling hits exactly when the most people are watching.

Better: emit deltas and apply one batched increment per video per second.
🚫
Sending "+1" from the client

Every retry, double tap and replay adds another like, and there is no way to tell them apart afterwards.

Better: send the desired state and let the server compute the delta from the edge transition.
🚫
Keeping only the count, with no edges

You cannot answer "has this viewer liked it", cannot remove fake likes, and cannot recover after the count drifts.

Better: store edges as the truth and treat the count as a derived, rebuildable value.
🚫
Showing exact, real-time counts to everyone

It forces strong consistency and uncached reads for a number most viewers glance at once.

Better: round for display, cache for a few seconds and reserve exact totals for creators.

09

How to run the interview

09

How to run the interview

1

Split truth from aggregate early. Say that the per-viewer reaction is the truth and the count is derived. It answers dedupe, fraud removal and drift in one move.

2

Find the hot key. Point out that total writes are manageable but one viral video concentrates them on one counter. Interviewers wait for you to notice.

3

Offer batching before sharding. Coalescing deltas per video in the stream removes the hot row. Then describe sharded counters as the fallback and why you rarely need them.

4

Make the write idempotent. Desired state in the request, delta from the transition, conditional write on the edge. Walk through a double tap and a retry.

5

Explain exactly-once honestly. The offset watermark on the count row is what makes replays safe. Say what would break without it.

6

Agree on staleness. Get the interviewer to accept a few seconds of delay and rounded display. Then show how the viewer's own tap still appears instantly.

7

Plan for correction. Fraud removals and a nightly recount show that you expect counts to go wrong and know how to bring them back.

10

Growing the design in stages

11

Growing the design in stages

Stage 1: a few thousand likes a day
Likes table and COUNT(*)

A likes table with a unique key on viewer and video, and a count computed on read. Perfectly correct and fine for a small site.

Stage 2: popular videos appear
Counter column updated in the same transaction

COUNT(*) gets slow on big videos, so a counter column is incremented with each insert. Hot videos start causing lock waits.

Stage 3: millions of reactions a day
Cache in front, asynchronous increments

Counts move behind a cache and are updated from a queue, so the like button stops waiting on the counter.

Stage 4: viral premieres
Keyed stream with batched deltas and watermarks

Deltas are keyed by video and aggregated for a second before each write. Offset watermarks make replays safe, and a nightly recount repairs drift.

Stage 5: global scale
Regional writes with a global rollup

Each region records edges and aggregates locally; a global job merges regional totals for display. The single counter column is gone, and exact totals live only in creator tools.

Try next

Free to read · better with landed

Whiteboard this with landed

landed runs it as a live system design round on the whiteboard and grades your trade-offs.