Design a Newsfeed: System Design & Architecture

Build the core of a social network: users follow each other and post updates, and every user sees a fresh, fast home feed of posts from the people they follow - even when one of those people has 20 million followers.

Scope

In Scope

  • Posting: text posts with attached media (images/video), visible to followers only
  • Follow graph: follow/unfollow, and everything downstream of it
  • Home feed: reverse-chronological posts from the accounts you follow, with cursor pagination
  • Reactions (likes) and flat comments, with counts
  • The hot-user (celebrity) problem: keeping the system healthy when one account has millions of followers

Out of Scope

  • Ranked or personalized feed ordering: we ship newest-first. Ranking is a machine-learning system in its own right, and cutting it keeps the focus on delivery - getting posts into millions of feeds quickly - which is the real heart of this question
  • Recommended content, trending, and search (separate discovery systems)
  • Nested comment threads (flat comments only, to keep the data model simple)
  • The media processing pipeline itself - transcoding, thumbnails, resolutions (that is the heart of Design YouTube); here media is just bytes in blob storage served via CDN
  • Ads, content moderation, and creator monetization
  • Stories and live video (different delivery systems)

💡 What does "fan-out" mean?

Fan-out is delivering one thing to many destinations. Here, when a user makes one post, that single post has to reach the home feed of every one of their followers: one post from a user with 200 followers becomes 200 feed deliveries, and one post from a celebrity with 20 million followers becomes 20 million. There are two ways to pay that cost: do the delivery work when the post is written (fan-out-on-write) or do it when each follower opens their feed (fan-out-on-read). Choosing between them - and knowing when to use each - is the central decision of this design, and it is exactly the "hot user handling" we put in scope.

Functional Requirements

  • A user can create a post with text and optional images or video
  • A user can follow and unfollow other users
  • A user sees a home feed of posts from only the people they follow, newest first, and a new post shows up in followers' feeds within seconds
  • A user can scroll back through their feed in pages without seeing duplicates or gaps
  • A user can like a post and see how many others have
  • A user can comment on a post and read other comments (flat - no nested replies)
  • A user can open a profile page and see that person's own posts

Non-functional Requirements

  • Availability over consistency (AP): a slightly stale feed is fine; the feed being down is not - users simply leave. Target 99.99% for the read path
  • Feed load latency: p99 < 200ms for the feed API call (media bytes are fetched separately from the CDN)
  • Post visibility: a new post appears in followers' feeds within ~10 seconds (the eventual-consistency window)
  • Read-your-own-writes: right after posting, the author sees their own post immediately
  • Scale: 200M registered users, 20M daily active; the design must absorb fan-out write amplification (see estimates)
  • Durability: an acknowledged post is never lost. Feed entries can be rebuilt; posts cannot

💡 What do "availability over consistency" and "eventual consistency" mean here?

On CAP theorem: during a network partition a system can stay consistent (everyone sees exactly the same data) or stay available (everyone gets an answer), but not both. A newsfeed picks availability: if part of the system cannot talk to another part, we would rather show you a feed that is missing the last few seconds of posts than show you an error page. Nothing about a feed needs to be perfectly up to the millisecond. Eventual consistency is the price of that choice. When someone posts, their followers see it a few seconds later, not instantly, and for a brief moment two followers might see slightly different feeds. Once the system catches up, everyone converges on the same posts. That is completely fine for social content - and it would be completely wrong for, say, a bank balance, which is why other designs make the opposite call. Read-your-own-writes is the one consistency promise we do keep: after you hit Post, you see your own post at once. Waiting even two seconds to see your own words feels broken, so the author's reads are served from the freshest copy while everyone else catches up within the sync window.

Back-of-the-Envelope Estimates

  • Assume 200M registered users, 20M daily active users (DAU), peak ~4M concurrent in evening hours
  • A write is anything that creates or changes data: making a post, liking, commenting, or following. A read fetches without changing: loading your feed, opening a profile, reading comments
  • Posting: ~1 post per active user per day → 20M posts/day ÷ 86,400 seconds ≈ 230 posts/sec average; a 10x peak factor gives ~2.3K posts/sec
  • Engagement: likes and comments run ~10x posting volume → ~200M/day ≈ 2.3K/sec average, ~23K/sec peak
  • Feed reads: each active user opens their feed ~5 times/day → 100M feed loads/day ≈ 1.2K/sec average, ~12K/sec peak. So at the API surface, reads comfortably outnumber post writes
  • The hidden multiplier is fan-out: the average user has ~200 followers, so 20M posts/day become 20M x 200 = 4B feed-entry deliveries/day ≈ 46K/sec average. At the storage layer this system is write-heavy, and this amplification - not the raw post count - is what the design has to absorb
  • Storage: a post row is ~1KB of metadata (the media bytes live in blob storage behind pointers) → 20M/day x 1KB ≈ 20GB/day, ~7TB/year. A feed entry is a ~100-byte pointer, capped at the newest ~500 per user → 200M x 500 x 100B ≈ 10TB steady state - which is exactly why feeds store pointers and are capped

💡 What the estimates mean, and how fan-out shapes the database choice

The numbers have a surprising shape: the system looks read-heavy at the API (far more feed loads than posts) but is write-heavy at the storage layer, because every post multiplies into hundreds of feed-entry writes. A good design has to serve both faces at once. For the write side, the load is a firehose of tiny, independent appends - "add this post id to this user's feed" - each touching exactly one user's data, with no transactions spanning rows and no joins. That is the textbook case for a horizontally scalable NoSQL store (DynamoDB or Cassandra) partitioned by user_id: every append lands on one partition, and adding servers adds throughput. Compare this with Design Calendly and Design Dropbox, which chose PostgreSQL because they needed relational guarantees - Calendly must make two overlapping bookings impossible, which takes transactions and constraints. A feed entry needs no such protection; if one is written twice or a few seconds late, nothing breaks. So we trade the relational guarantees we do not need for the raw append throughput we very much do. For the read side, the trick is that the Feed Store IS the answer to the feed query, computed ahead of time. A feed load does not compute anything interesting - it reads the precomputed list, tops it up from a couple of caches, and returns. Redis absorbs the first-page loads of active users, and the recent posts of hot accounts are cached once and served to the millions of readers who follow them. Two more choices fall straight out of the estimates. First, the bytes-versus-metadata split, same as Design Dropbox: images and video live in blob storage behind a CDN, and the database stores only URLs - that is why 20M posts/day is a mere ~20GB/day of database growth. Second, denormalized counters: we store like_count on the post row rather than counting reaction rows on every render, paying a little extra work on each write to keep the massively more frequent reads cheap.

High-level Architecture

  • API Gateway: Authenticates users and rate-limits the write endpoints (posting, following, reacting) - the cheap first defense against spam and bot rings
  • Post Service: Validates and stores the post row. Media was already uploaded directly to blob storage via presigned URLs, so this service only ever handles text and pointers
  • Fan-out Queue + Workers: The post is acknowledged immediately and a fan-out job goes on a durable queue; workers read the author's follower list and append a pointer into each follower's feed - asynchronously, in batches, with retries
  • Follow (Social Graph) Service: Stores who-follows-whom, including the reverse index (who follows X) that fan-out depends on
  • Feed Service: The read path. Merges the user's precomputed feed entries with fresh posts pulled from any hot accounts they follow, hydrates post ids into full posts, and serves pages by cursor
  • Feed Store (NoSQL, partitioned by user): Each user's precomputed home feed - small pointer entries, capped to the newest ~500
  • Posts DB (NoSQL, partitioned by author): The single source of truth for post content; every feed entry ultimately points here
  • Cache (Redis): First feed pages for active users, recent-post lists of hot accounts, and buffered reaction counters
  • Blob Storage + CDN: The actual image/video bytes, uploaded directly by clients and downloaded from CDN edges - never through our services

Data/User Flow Diagram

WRITE PATH - making a post
[Client] --(media via presigned URLs)--> [Blob Storage] --> [CDN]
[Client] --> [API Gateway] --> [Post Service] --> [Posts DB]
                                    |
                                    v  (ack the poster immediately)
                            [Fan-out Queue]
                                    |
                                    v
                            [Fan-out Workers] --who follows X?--> [Follow Graph]
                                    |
                                    v  append (post_id, author, ts), batched
                            [Feed Store (NoSQL, per-user partitions)]
        (hot accounts with huge followings are NOT fanned out - see read path)

READ PATH - opening the app
[Client] --> [API Gateway] --> [Feed Service] --> [Feed Cache (Redis)]
                                    | (miss)
                                    +--> [Feed Store]  newest ~50 pointer entries
                                    +--> [Posts DB]    recent posts of followed
                                    |                  hot accounts (heavily cached)
                                    v
                       merge by time -> hydrate ids into posts -> return page
                       (client fetches media bytes from the CDN separately)

💡 Two terms from the diagram: queue and hydration

Queue: a durable buffer between "the post was saved" and "the post reached every follower's feed." The poster gets their acknowledgment the moment the post row is written; the 200 (or 20 million) feed deliveries happen afterwards, in the background. The queue absorbs bursts (a big event where everyone posts at once), lets us retry failed deliveries safely, and means a slow fan-out never makes posting feel slow. Hydration: feed entries are just pointers - a post_id, an author_id, and a timestamp. Turning that list of ids into actual posts (text, media URLs, like counts) is called hydrating, and it is one cheap batched lookup against the Posts DB. Keeping feeds as pointers and hydrating at read time is what makes editing and deleting posts trivial: there is only ever one real copy of the post.

Data Model & Storage

  • Posts (NoSQL - DynamoDB/Cassandra, partitioned by author_id): post_id, author_id, ts, text, media_urls[], like_count, comment_count, deleted. NoSQL because this is huge volumes of independent writes with simple access patterns (by post, or by author newest-first) and no need for joins or cross-row transactions.
  • Follow Graph (NoSQL, partitioned by user_id): the "following" edges, plus a reverse index (followee_id → follower ids). The reverse index is not optional - fan-out's entire job is answering "who follows X," and without the index that query would be a full scan.
  • Feed Store (NoSQL, partitioned by user_id, sorted by ts): each user's precomputed feed as ~100-byte pointer entries (post_id, author_id, ts), capped at the newest ~500. Rebuildable from Posts + Follow Graph, so losing it is expensive but never loses a post.
  • Reactions (NoSQL, partitioned by post_id): one row per (post_id, user_id) with the reaction type - which makes liking naturally idempotent (liking twice is the same row). Counts are denormalized onto the post row.
  • Comments (NoSQL, partitioned by post_id, sorted by ts): flat comment rows. No parent_comment_id because nesting is out of scope.
  • Media (Blob Storage + CDN): the actual bytes. The database only ever stores URLs pointing here - the same bytes-versus-metadata split as Design Dropbox.
  • Cache (Redis): first feed pages, hot accounts' recent post ids, and buffered like counters. Cache only - losing it costs speed, never data.

💡 Why the feed stores pointers instead of copies of the post

When a post is fanned out to 200 followers, we could copy the whole post (text, media URLs, counts) into all 200 feeds, or we could write 200 tiny pointers that all reference the one real post. Pointers win for three reasons. Size: a full post row is ~1KB; a pointer is ~100 bytes. At 4 billion feed deliveries a day, copies would be ~4TB/day of feed writes versus ~400GB/day for pointers - a 10x difference on the most write-amplified table in the system. Change in one place: if the author edits or deletes the post, or the like count changes, there is exactly one row to update. With copies, a delete would mean hunting down 200 - or for a celebrity, 20 million - embedded duplicates. With pointers, hydration simply skips a post marked deleted, and every feed is instantly correct. Capping: because entries are tiny and reference-only, we can keep each user's feed trimmed to the newest ~500 entries without losing anything - older posts still exist in the Posts DB and remain reachable from profile pages. The Feed Store stays a small, fast, fixed-size structure per user instead of growing forever.

API Design

POST /v1/posts
Create a post (text + media URLs from a prior direct upload). Accepts an Idempotency-Key header so a retried request never double-posts.
GET /v1/feed?cursor=...&limit=20
The home feed: merged, hydrated posts from followed accounts, newest first, cursor-paginated.
POST /v1/users/:id/follow
Follow a user; backfills their recent posts into your feed. DELETE to unfollow.
POST /v1/posts/:id/reactions
Like (or otherwise react to) a post; idempotent per user. DELETE to remove.
POST /v1/posts/:id/comments
Add a flat comment to a post.
GET /v1/posts/:id/comments?cursor=...
Read a post's comments, paginated.
GET /v1/users/:id/posts?cursor=...
A profile page: that user's own posts, newest first.

Low-level Design

Post Service - creating a post (the write path, step by step):

  • 1) The client uploads any images or video directly to blob storage using presigned URLs (the same direct-upload pattern as Design Dropbox) and gets back media URLs.
  • 2) The client calls POST /v1/posts with the text and media URLs; the service writes one row to the Posts DB.
  • 3) The service enqueues a fan-out job and returns success immediately - the poster never waits for feed delivery, which is why posting feels instant regardless of follower count.
  • 4) Fan-out workers consume the job, read the author's follower list from the Follow Graph's reverse index, and append a pointer entry (post_id, author_id, ts) to each follower's Feed Store partition, in batches.
  • 5) Failed batches are retried safely: feed entries are keyed by (user_id, post_id), so writing the same entry twice just overwrites itself - the workers are idempotent.

Fan-out Service - hot users (the genuinely tricky part):

  • A regular user with ~200 followers costs 200 feed writes per post - cheap. An account with 20M followers would cost 20M writes for a single post, and a burst of posts from big accounts would bury the workers and hammer millions of partitions at once.
  • So we split strategies. Accounts above a follower threshold (say 100K) are flagged hot, and their posts are NOT fanned out at all - they are only written to the Posts DB.
  • At read time, the Feed Service pulls the recent posts of the hot accounts you follow and merges them into your feed. Most people follow only a handful of hot accounts, and millions of readers ask for the same celebrity's posts, so this pull path is served almost entirely from cache.
  • This hybrid is push for the many, pull for the few: fan-out-on-write for regular users, fan-out-on-read for celebrities.
  • The tradeoff is real: every feed read now does a little merging work, in exchange for a hard guarantee that no single post can ever trigger 20M writes.

Feed Service - loading the home feed (the read path, step by step):

  • 1) Check Redis for a cached first page ("feed:{user_id}"); active users usually hit it and we are done.
  • 2) On a miss, read the newest ~50 pointer entries from the user's Feed Store partition.
  • 3) Fetch the recent post ids of any hot accounts this user follows (from the shared "recent:{author_id}" cache).
  • 4) Merge the two lists by timestamp, newest first.
  • 5) Hydrate: batch-fetch the full posts for those ids from the Posts DB, skip any marked deleted, attach CDN URLs for media, and return the page plus a next_cursor.
  • The heavy lifting all happened earlier, at write time - which is the whole point.

Feed Service - pagination that never duplicates or skips:

  • The cursor is the (ts, post_id) of the last item returned; the next page asks for entries strictly older than that.
  • Why not page numbers: new posts arrive constantly, so "page 2" computed by offset shifts underneath you and shows repeats. A cursor pins your place in the timeline no matter how much is added above it.
  • post_id is the tie-break for two posts created in the same millisecond, so the order is stable everywhere.

Reaction Service - likes without melting a counter:

  • One row per (post_id, user_id) makes a like naturally idempotent: liking twice writes the same row, and unliking deletes it.
  • The like_count shown on the post is denormalized - kept on the post row - so rendering a feed never counts reaction rows.
  • The catch is viral posts: thousands of likes per second all incrementing one counter is a hot key. So we buffer increments in Redis and flush them to the Posts DB in batches every few seconds, accepting a slightly approximate on-screen count.
  • A periodic reconciliation job recounts the actual reaction rows and corrects any drift - approximately right now, exactly right eventually.

Follow Service - follow, unfollow, and delete (the lazy-cleanup pattern):

  • Following writes an edge to the Follow Graph (both the forward edge and the reverse index that fan-out reads).
  • On follow we backfill: copy the followee's ~10 most recent post ids into your feed, so they show up immediately instead of after their next post.
  • On unfollow we do NOT scrub the Feed Store - we cheaply filter that author's entries out at hydration time until they age out of the capped feed.
  • Deleting a post works the same lazy way: mark the post row deleted (a tombstone), and hydration skips it everywhere at once.
  • Because feeds hold pointers rather than copies, we never chase down and edit 200 - or 20 million - copies of anything.

Database Types and Schemas

Database Schemas (NoSQL: DynamoDB / Cassandra - shown as key design)

- Posts (partitioned by author)
  PK: author_id, SK: ts (newest first)
  Attributes: post_id, text, media_urls[], like_count, comment_count, deleted
  GSI: post_id -> item (direct lookup, used by hydration and permalinks)
  -- one row per post; media bytes live in blob storage, only URLs here
  -- deleted is a tombstone: hydration skips it, no feed cleanup needed

- Follow Graph
  Following table -> PK: user_id,     SK: followee_id   ("who do I follow")
  Reverse index   -> PK: followee_id, SK: follower_id   ("who follows X")
  Attributes: created_at, followee_is_hot
  -- fan-out cannot exist without the reverse index: when X posts, the
  -- workers need X's followers, which the forward table cannot answer

- Feed Store (precomputed home feeds - pointers only)
  PK: user_id, SK: ts#post_id (newest first)
  Attributes: author_id
  -- capped at the newest ~500 entries per user; older entries age out
  -- ~100 bytes per entry because it holds ids, never post content
  -- rebuildable from Posts + Follow Graph; losing it loses no posts

- Reactions
  PK: post_id, SK: user_id
  Attributes: reaction_type ('like' | 'love' | ...), ts
  GSI: user_id -> posts this user reacted to (activity history)
  -- one row per (post, user): a like is idempotent by construction

- Comments (flat - nesting is out of scope)
  PK: post_id, SK: ts#comment_id
  Attributes: user_id, text

Redis keys

- Feed Cache (first hydrated page per active user)
  Key: "feed:{user_id}" -> [ {post...}, {post...} ]
  TTL: ~60s; also invalidated when fan-out appends for this user

- Hot-account recent posts (the pull path, shared by millions of readers)
  Key: "recent:{author_id}" -> [post_id, post_id, ...]
  TTL: ~30s

- Buffered reaction counters (viral-post hot key protection)
  Key: "likes:{post_id}" -> pending increments
  Flushed to the Posts DB in batches every few seconds

API Request/Response Payloads

POST /v1/posts
Request
{
  "text": "Sunset from the pier",
  "media_urls": [
    "https://cdn.example.com/media/m_7f2a91.jpg"
  ]
}
Response
{
  "post_id": "p_8f3e2d1c",
  "author_id": "u_123",
  "ts": "2026-07-05T18:04:11Z",
  "visibility": "followers"
}

// The media was already uploaded directly to blob storage via presigned
// URLs (see Design Dropbox for that flow) - this call stores only text and
// pointers. An Idempotency-Key header means a retried request never
// double-posts. Fan-out to followers happens asynchronously after this
// response returns.
GET /v1/feed?cursor=2026-07-05T18:05:00Z%23p_0000&limit=20
Response
{
  "items": [
    {
      "post_id": "p_9a1b44",
      "author": { "user_id": "u_456", "name": "dana" },
      "ts": "2026-07-05T18:03:40Z",
      "text": "shipping day!",
      "media_urls": [],
      "like_count": 41,
      "viewer_has_liked": false
    },
    {
      "post_id": "p_77c2d0",
      "author": { "user_id": "u_bigband", "name": "The Big Band" },
      "ts": "2026-07-05T17:58:02Z",
      "text": "Tour dates are live",
      "media_urls": ["https://cdn.example.com/media/m_1182.jpg"],
      "like_count": 182304,
      "viewer_has_liked": true
    }
  ],
  "next_cursor": "2026-07-05T17:58:02Z#p_77c2d0",
  "has_more": true
}

// The first item came from this user's precomputed Feed Store entries.
// The second came from the pull path (a hot account, never fanned out)
// and was merged in by timestamp. Its like_count is approximate - it is
// served from the buffered counter.
POST /v1/posts/:id/reactions
Request
{
  "type": "like"
}
Response
{
  "ok": true,
  "like_count": 182305
}

// Idempotent: liking a post you already liked is a no-op and returns the
// same result. On viral posts the returned count is approximate (buffered
// in Redis, flushed in batches, reconciled periodically).
POST /v1/users/:id/follow
Response
{
  "following": true,
  "backfilled_posts": 10
}

// On follow, the followee's ~10 most recent post ids are copied into your
// feed so they appear immediately - otherwise you would see nothing from
// them until their next post.
Error responses (examples)
Response
// 429 Too Many Requests - write endpoints are rate limited per user
{
  "error": "rate_limited",
  "message": "Too many posts. Try again in a minute.",
  "retry_after_seconds": 60
}

// 404 Not Found - post does not exist OR is not visible to the caller
{
  "error": "not_found",
  "message": "Post not found"
}
// Follower-only posts return the same 404 as nonexistent ones on purpose,
// so outsiders cannot probe which post ids exist.

💡 Three terms from the flows: fan-out-on-write, fan-out-on-read, cursor

Fan-out-on-write: deliver the post to every follower's feed at the moment it is posted. The write is expensive (one append per follower) but every later feed read is a cheap, precomputed lookup. This is the right default because feeds are read far more often than posts are written. Fan-out-on-read: deliver nothing up front; when a follower opens their feed, go fetch the recent posts of the people they follow. The write is free but every read does real work. We reserve this for hot accounts, where fan-out-on-write would mean millions of writes per post - and we lean on caching, since millions of readers want the same few posts. Cursor: a bookmark marking exactly where the last page ended - here, the timestamp plus post id of the last item. The next page asks for "everything older than this." Unlike page numbers, a cursor stays correct while new posts pile on top of the feed. The same idea drives sync in Design Dropbox; here it drives scrolling.

Concluding Discussion

Potential Bottlenecks

  • Issue: A post from an account with millions of followers would trigger millions of feed writes, saturating the fan-out workers and the Feed Store.
    Mitigation: the hybrid push/pull split - accounts over the follower threshold are never fanned out; their posts are pulled and merged at read time from a heavily cached path.
  • Issue: A viral post concentrates thousands of reaction writes per second on a single counter (a hot key).
    Mitigation: buffer increments in Redis, flush in batches, reconcile against the reaction rows periodically, and accept approximate counts on screen.
  • Issue: A global moment (breaking news, a big game) spikes posting and the fan-out queue backs up, delaying post visibility.
    Mitigation: fan-out jobs are independent and idempotent, so workers scale horizontally; under extreme load, visibility degrades to slightly-later delivery rather than lost work.

Single Points of Failure

  • The fan-out queue: if it is down, new posts stop reaching feeds. Run it clustered across zones; note the graceful failure - posts are still written and readable on profiles, so feeds go stale rather than data being lost.
  • The Redis cache: cache-only, so losing it loses no data - but a cold restart would stampede the Feed Store and Posts DB. Run it clustered and warm the hottest keys on recovery.
  • The Follow Graph store sits on both paths (fan-out reads it on every post, the feed merge reads it on every load). Run it with replicas, and cache the follower lists and hot-account sets aggressively.

Security Improvements

  • Rate-limit posting, following, and reacting per user at the gateway - the cheap defense against spam floods, mass-follow bots, and like-farms.
  • Enforce visibility on the server at hydration time: posts are follower-only, so the follow edge (and block lists) are checked when the feed is built, never just hidden in the UI.
  • Serve media through signed, expiring CDN URLs so a leaked link to a follower-only photo stops working after the expiry window.

Critical Knowledge

If the interviewer asks "why precompute feeds at write time instead of building them at read time?":

  • Pure fan-out-on-read means every feed load scatter-reads the recent posts of everyone you follow - hundreds of lookups per load, at ~12K feed loads/sec peak. Posts are written ~100x less often than feeds are read, so it is far cheaper to do the expensive delivery work on the rare event (the post) and make the frequent event (the read) a precomputed lookup.
  • But pure fan-out-on-write breaks on celebrities: one post, 20M writes.
  • So the real answer is the hybrid: push for regular accounts, pull (with heavy caching) for accounts over a follower threshold. Naming both strategies and the crossover point is exactly what the interviewer is fishing for.

If the interviewer asks "why NoSQL here when your Calendly and Dropbox designs used PostgreSQL?":

  • It comes down to what the data needs. Calendly needed transactions and a database-enforced no-overlap constraint - two bookings for the same slot must be impossible, and that is what relational databases are for.
  • A newsfeed has no such invariant: feed entries are independent little facts, written twice harmlessly, arriving seconds late harmlessly. What it does have is brutal write volume (46K feed appends/sec average from fan-out) and dead-simple access patterns (by user, by author, newest first).
  • That is the exact profile where a partitioned NoSQL store shines: every write hits one partition, throughput scales by adding machines, and we denormalize (like_count on the post) instead of joining.
  • Pick the store from the access pattern, not from habit - being able to argue both directions is the point.

If the interviewer asks "why are the like counts approximate?":

  • Because a viral post turns one counter into a hot key - thousands of increments per second targeting a single row, which no store enjoys.
  • So increments buffer in Redis and flush in batches, meaning the on-screen number can lag reality by a few seconds, and a periodic job recounts the true reaction rows to fix any drift.
  • The reaction rows themselves (one per user per post) are always exact - it is only the displayed total that is eventually consistent.
  • The judgment call to articulate: nobody makes a decision based on 182,304 vs 182,309 likes, so exactness is not worth the cost here - and then note where that logic flips, like billing or inventory, where you would pay for exact counts.

If the interviewer asks "how does pagination stay correct while new posts keep arriving?":

  • Offset pagination ("give me items 20-40") breaks on a live feed: ten new posts arrive between your page loads, everything shifts down, and page two repeats half of page one.
  • Cursor pagination fixes this by anchoring to content, not position: the cursor is the (timestamp, post_id) of the last item you saw, and the next page asks for entries strictly older than it. New posts stack on top without disturbing your place.
  • The post_id tie-break keeps ordering stable when two posts share a timestamp.
  • This is the same cursor idea as the change feed in Design Dropbox - a bookmark into an ordered stream - applied to scrolling instead of syncing.

If the interviewer asks "where do the images and videos actually live?":

  • Never in the database. Clients upload bytes directly to blob storage via presigned URLs, downloads come from CDN edges, and our services and database only ever touch URLs - the same bytes-versus-metadata split argued in Design Dropbox.
  • This is why a post row is ~1KB and post storage grows a mere ~20GB/day at 20M posts/day, and why a viral photo hammers the CDN (built for exactly that) instead of our feed path.
  • Feeds are doubly insulated: the Feed Store holds pointers to posts, and posts hold pointers to media.
  • Transcoding, thumbnails, and resolutions are their own pipeline - that is the heart of Design YouTube, which is why we scoped it out here.