System Design
C5

News feed

A user posts, and their followers see it in their home feed, newest first. The design question is when to do the work: when the post is written, when the feed is read, or a mix.

Not startedSaved in this browser only.
  1. 1Do the work at post time: fan out post IDs into a capped timeline per active reader. A feed read is then one sorted-set range.
  2. 2Do not push large accounts. Pull their newest posts at read time and merge. The worst post drops from millions of writes to none.
  3. 3Timelines hold IDs only and are a cache. Posts, follows and deletes live in Postgres, and any timeline can be rebuilt.
  4. 4IDs sort by time, so a cursor (the last ID seen) pages correctly while new posts arrive.
C5
    A

    The prompt

    as the interviewer says it
    "Design the home feed of a social app: a user posts, their followers see it in order, at hundreds of millions of users."
    questionanswer assumed
    Which feed?Home feed: posts of accounts I follow. Discovery is out of scope.
    Order?Newest first. Ranking is a follow-up.
    How many users?500 million daily. 200 follows on average.
    Largest account?100 million followers.
    How fresh?Followers see a post within 5 s. The author sees it at once.
    Post size?Text up to 500 bytes. Images and video by upload.
    Deletes?A deleted post must vanish from every feed at once.

    Before I draw anything, I confirm the feed type, the order, the scale and how fresh the feed must be.

    B

    Requirements

    as numbers
    functional
    Post text with media; delete a post.
    Follow and unfollow an account.
    Read the home feed, newest first, 20 posts a page, with a cursor.
    non-functionaltarget
    Feed read latencyp99 < 200 ms
    Post to follower's feedp99 < 5 s
    Feed availability99.95%
    ConsistencyEventual for followers; read-your-writes for the author
    Reads per post5 B ÷ 100 M = 50

    Feed reads outnumber posts 50 to 1, so I spend work at write time to make reads cheap.

    C

    Estimates

    the arithmetic, on the board
    quantityarithmeticresult
    Feed loads500 M users × 10 a day = 5 B ÷ 86,400≈ 58,000/s; 3× peak ≈ 174,000/s
    Posts500 M × 0.2 a day = 100 M ÷ 86,400≈ 1,200/s; peak ≈ 3,500/s
    Timeline writes, push to all100 M posts × 200 followers = 20 B ÷ 86,400≈ 231,000/s
    Push to active followers only× 0.40 (simulated: 40% of users active)≈ 93,000/s
    Timeline cache500 M timelines × 128 IDs × 31.4 B (measured)≈ 2.0 TB of memory
    Post storage100 M × 500 B a day50 GB a day ≈ 18 TB a year
    Media10 M images × 200 KB a day2 TB a day, in object storage
    Feed bandwidth58,000/s × 20 posts × 500 B≈ 4.6 Gbit/s, media excluded

    Inputs are the assumed answers in panel A. Bytes per entry measured on Redis in the lab; the active share comes from the simulation in panel H. Method: R2.

    Feed loads are about 58,000 a second and posts about 1,200. Pushing every post to every follower is 231 thousand timeline writes a second, so who gets a push matters.

    D

    API

    endpoints, status codes, retries
    callforretry safe bysuccesserrors
    POST /v1/posts {body, media_ids} + Idempotency-Keypublishthe idempotency key201 + id400, 413 too long
    DELETE /v1/posts/{id}deleteDELETE is idempotent204403, 404
    PUT /v1/me/following/{user_id}followPUT is idempotent204404
    DELETE /v1/me/following/{user_id}unfollowDELETE is idempotent204404
    GET /v1/feed?cursor=&limit=20home feeda read200 + posts, next_cursor400 bad cursor
    POST /v1/media {type, bytes}upload URLthe media id201 + media_id, upload_url413
    • The cursor is the last post ID the client saw, encoded as an opaque string.
    • The client uploads media straight to object storage with a presigned URL (B9), then posts the media ID.

    The feed endpoint takes a cursor, not a page number. A post carries an idempotency key, so a retried request never posts twice.

    E

    Data model

    Postgres is the truth, Redis is the cache
    the truth: three tables in Postgresn : m1 : nusersPKidUQhandlefollower_countlast_active_atpush or pull; skip inactivefollowsPK FKfollower_idPK FKfollowee_ididx (followee_id, follower_id)postsPKidtime-orderedFKauthor_idbodymedia_keyCDNdeleted_atidx (author_id, id DESC)
    Redis keyholdswritten byread by
    tl:{user}Sorted set, at most 128 post IDs of pushed accounts. Score: the post's millisecond.Fan-out workersFeed reads
    au:{author}Sorted set, the author's own newest 128 postsFan-out workersReads of large accounts; rebuilds
    users, follows, postssql
    CREATE TABLE users (
      id             bigint      PRIMARY KEY,
      handle         text        NOT NULL UNIQUE,
      follower_count bigint1      NOT NULL DEFAULT 0,
      last_active_at timestamptz2 NOT NULL DEFAULT now()
    );
    
    CREATE TABLE follows (
      follower_id bigint      NOT NULL REFERENCES users (id),
      followee_id bigint      NOT NULL REFERENCES users (id),
      created_at  timestamptz NOT NULL DEFAULT now(),
      PRIMARY KEY (follower_id, followee_id)
    );
    CREATE INDEX follows_followee ON follows (followee_id, follower_id);3
    
    CREATE TABLE posts (
      id         bigint PRIMARY KEY4,
      author_id  bigint NOT NULL REFERENCES users (id),
      body       text   NOT NULL,
      media_key  text,
      deleted_at timestamptz5
    );
    CREATE INDEX posts_author ON posts (author_id, id DESC);
    1. 1Kept with the user, so fan-out decides push or pull with one lookup.
    2. 2Fan-out writes only to followers seen in the last 30 days.
    3. 3The reverse direction: every follower of an author, in one index range.
    4. 4A time-ordered 64-bit ID: milliseconds, node, sequence. Sorting by ID sorts by time.
    5. 5A soft delete. Hydrate skips the row at once; the cleanup job removes the ID from timelines later.

    Postgres holds users, follows and posts. Redis holds two sorted sets per user: the timeline others push into, and the user's own newest posts.

    F

    The architecture

    click a step; its path lights up
    Clientapp, browserPost and feed APIstatelessCDNmedia bytesPostgres primaryposts, followsEvent logpost created, deletedFeed servicemerge, hydrateFan-out workersone job per postPostgres replicasfollowers, posts by IDRedis clustertimelines, own lists

    Step 1: Post

    • Give the post a time-ordered ID, so IDs sort by time.
    • Insert the post and a "post created" event in one transaction (an outbox).
    • Return 201 with the ID. The author sees the post at once.

    If it fails

    The insert fails: return 5xx; the client retries with its idempotency key. The event relay is down: events wait in the outbox table.

    The post commits in Postgres with an event. Workers fan the ID out to Redis timelines. The feed service merges, hydrates and returns 20 posts.

    G

    Capabilities used

    what each tool gives you
    toolcapabilitywhat it gives this designalso used for
    RedisSorted sets: ZADD, ZRANGE BYSCORE REV LIMIT (ZREVRANGEBYSCORE before 6.2)A timeline ordered by time; one page is one range read.Leaderboards, delay queues
    RedisZREMRANGEBYRANKTrim to the newest 128 in the same round trip as the add.Recent-items lists
    RedisListpack encoding up to 128 entries31.4 bytes per entry, against 101.9 as a skip list.Small hashes and lists
    RedisPipeliningFan-out sends 500 followers' writes per round trip.Any batch of commands
    RedisCluster: keys spread by hash slotTimelines spread over nodes by user. No timeline spans two nodes.Sessions, caches
    RedisScores are float64Limit Exact only to 2^53, so the score is the millisecond, not the ID.
    PostgresComposite index (followee_id, follower_id)All followers of an author in one range, for fan-out.Any reverse lookup on a link table
    Postgresbigint primary key from a time-ordered IDInserts land at the right edge of the index; ID order is time order.Orders, events, messages
    PostgresStreaming replicasFan-out and hydrate read from replicas, not the primary.Read scaling
    PostgresTransactions with an outbox tableThe post and its event commit together, so no post misses its fan-out.Any change that must emit an event
    KafkaPartitioned log keyed by authorFan-out jobs in order per author; workers scale with partitions.Notifications, search indexing
    CDNEdge caches in front of object storageMedia never passes through the API.Avatars, static files

    Redis sorted sets give me a capped, time-ordered list per user with range reads. Postgres gives me the truth and the reverse follow index.

    H

    Deep dive: push, pull or hybrid

    who pays for a post
    strategypost costsfeed load costsstatus
    Pull: merge every followed account at read time1 write33.1 reads on average, p99 206Few follows, low rate
    Push to every followerUp to 41,326 writes for one post in the simulation1 readNo large accounts
    Push to active followersMean 13.2, against 33.01 read; a rebuild on returnApproved
    Hybrid: push below a threshold, pull above itWorst 3,846 at a 10,000-follower threshold2.9 reads on averageApproved
    Push post bodies, not IDsEvery edit rewrites every copy1 readNot approved
    • The threshold is a number in config. Raise it if reads cost too much; lower it if fan-out lags.
    • An account that crosses the threshold changes mode for new posts only. Old entries stay until trimmed.
    post and fan outpseudo code
    post(author, body):
      id = next time-ordered ID1                    // 41 bits ms, node, sequence
      insert the post into Postgres                // the truth
      add id to the author's own list2              // ZADD au:{author}
      IF followers(author) >= THRESHOLD: RETURN3    // large account: readers pull it
      FOR EACH batch of 500 active followers4 f:    // one round trip per batch
        ZADD tl:{f} score=ms(id) member=id
        ZREMRANGEBYRANK5 tl:{f} 0 -(CAP+1)          // keep the newest CAP
    1. 1A Snowflake-style ID: the millisecond is the high bits, so IDs sort by time (X4).
    2. 2Every author keeps a list. Readers of a large account read it; a rebuild reads it too.
    3. 3The hybrid in one line. A large account costs nothing at post time.
    4. 4Pipelined. One worker wrote 106,000 entries a second to one Redis in the lab.
    5. 5Trim in the same round trip. The timeline never grows past the cap.
    Tested source Go: post, deliver, fan out
    Go: post, deliver, fan outgo
    // Post stores the post, adds it to the author's own list, and pushes it to the timelines of
    // active followers, unless the author has Threshold followers or more. It returns the post ID and
    // the number of timelines written.
    func (s *Service) Post(ctx context.Context, author int64, body string) (int64, int, error) {
      id, err := s.IDs.Next()
      if err != nil {
        return 0, 0, fmt.Errorf("post id: %w", err)
      }
      var followers int64
      if err := s.DB.QueryRow(ctx, Q("insert_post"), id, author, body).Scan(&followers); err != nil {
        return 0, 0, fmt.Errorf("insert post: %w", err)
      }
      n, err := s.Deliver(ctx, author, id, followers)
      return id, n, err
    }
    
    // Deliver puts a stored post into the author's own list, then into followers' timelines unless
    // the author is pulled.
    func (s *Service) Deliver(ctx context.Context, author, id, followers int64) (int, error) {
      if err := s.add(ctx, s.authored(author), id); err != nil {
        return 0, err
      }
      if followers >= s.Threshold {
        return 0, nil // a large account: readers pull its posts
      }
      return s.FanOut(ctx, author, id)
    }
    
    // FanOut writes one entry into the timeline of every active follower, in pipelined batches. Each
    // timeline is trimmed to Cap entries in the same round trip.
    func (s *Service) FanOut(ctx context.Context, author, id int64) (int, error) {
      targets, err := s.followers(ctx, author, s.ActiveSince)
      if err != nil {
        return 0, err
      }
      for chunk := range slices.Chunk(targets, max(s.Batch, 1)) {
        _, err := s.RDB.Pipelined(ctx, func(p redis.Pipeliner) error {
          for _, f := range chunk {
            key := s.timeline(f)
            p.ZAdd(ctx, key, redis.Z{Score: score(id), Member: member(id)})
            p.ZRemRangeByRank(ctx, key, 0, int64(-s.Cap-1))
          }
          return nil
        })
        if err != nil {
          return 0, fmt.Errorf("fan out post %d: %w", id, err)
        }
      }
      return len(targets), nil
    }
    

    I push most authors and pull accounts with very many followers. In the simulation, pulling 10 accounts cut the worst post from 16,499 writes to 3,846, and reads rose from 1 to 2.9 per load.

    I

    See it: move the threshold

    recorded from a seeded simulation of 100,000 users
    users by follower count (both axes log)1101001k10k100k26,88069,1703,731202101101001k10kfollowers →pull from 10k
    pushed to timelinespulled at read time
    worst post (writes, log) against reads per feed load101001k10k100k0102030mean reads per feed load →10k followers
    Each point is one threshold. Left end: all push. Right end: all pull.
    100,000 users, one post eachthis settingall pushall pull
    Accounts pulled at read time100100,000
    Timeline writes per post: mean12.413.20.0
    Timeline writes for the worst post3,84616,4990
    Reads per feed load: mean / p992.9 / 91.0 / 133.1 / 206
    Timeline cache36.2 MB38.3 MB0.0 MB

    The worst post writes 3,846 timeline entries: about 36 ms at the measured 106,201 entries a second. Each feed load merges 2.9 sources on average.

    the simulationpseudo code
    simulate(graph, THRESHOLD, active_only):
      FOR EACH author:                             // every user posts once1
        IF followers(author) < THRESHOLD:
          writes += followers (active ones only, if asked2)
      FOR EACH active reader:                      // every active user loads once
        reads = 1 + followed accounts at or above THRESHOLD
      memory = SUM over timelines of min(received, CAP)3
    1. 1A power-law graph: popularity falls as 1 / rank^0.8. Median 14 followers, top account 41,326.
    2. 240% of users count as active. Inactive users get no timeline and no writes.
    3. 3Memory counts entries kept after the cap of 128. Bytes per entry come from Redis.
    Tested source Go: cost of one threshold
    Go: cost of one thresholdgo
    // Simulate computes the cost of one threshold. With activeOnly, fan-out skips inactive followers,
    // and inactive users keep no timeline; they get one rebuilt by a pull when they return.
    func (g *Graph) Simulate(threshold int, activeOnly bool, capEntries int) Cost {
      c := Cost{Threshold: threshold, ActiveOnly: activeOnly}
      pulled := make([]bool, g.Users)
      for u := range g.Users {
        pulled[u] = len(g.Followers[u]) >= threshold
        if pulled[u] {
          c.Pulled++
        }
      }
      keeps := func(u int32) bool { return !activeOnly || g.Active[u] }
    
      writes := make([]int, 0, g.Users) // one post per user
      received := make([]int, g.Users)  // timeline entries per reader
      for author := range g.Users {
        w := 0
        if !pulled[author] {
          for _, f := range g.Followers[author] {
            if keeps(f) {
              w++
              received[f]++
            }
          }
        }
        writes = append(writes, w)
      }
    
      var reads []int
      for u := range g.Users {
        if !g.Active[u] {
          continue // only active users load the feed
        }
        r := 0
        if threshold > 0 {
          r = 1 // the timeline itself
        }
        for _, v := range g.Following[u] {
          if pulled[v] {
            r++ // the newest posts of one pulled author
          }
        }
        reads = append(reads, r)
      }
    
      for u := range g.Users {
        if threshold > 0 && keeps(int32(u)) {
          c.Timelines++
          c.Entries += int64(min(received[u], capEntries))
        }
      }
      c.WritesMean, c.WritesP99, c.WritesMax = stats(writes)
      c.ReadsMean, c.ReadsP99, c.ReadsMax = stats(reads)
      return c
    }
    

    Moving the threshold trades the worst post against the read cost. One threshold near the top accounts removes most of the write spike for a few extra reads.

    J

    Deep dive: the timeline in Redis

    capped, ordered, paged by cursor
    the timeline, newest first, after 4 new posts343332313029282726252423222120191817161514131211post #newpage 1, already seenoffsetskip 1024232221201918171615✕ 4 repeatscursorafter #2120191817161514131211✓ no repeat, no gap
    read one pagepseudo code
    page(reader, cursor, n):
      sources = tl:{reader}
                + au:{a} FOR EACH large account a the reader follows1
      IN ONE ROUND TRIP, FOR EACH source:
        ZRANGE source ms(cursor) -inf BYSCORE REV LIMIT 0 n2
        keep ids < cursor3                          // ties in one ms
      merge all, newest first, drop repeats4
      RETURN the first n                           // next cursor = last id5
    1. 1The pull half of the hybrid: a few extra sorted sets, read in the same round trip.
    2. 2Newest first, starting at the cursor's millisecond. Inclusive, because other posts can share that millisecond.
    3. 3Members with equal scores sort by member. IDs are zero-padded, so member order is ID order.
    4. 4A post can sit in two sources while an account crosses the threshold.
    5. 5The ID carries its own time, so the cursor needs nothing else.
    Tested source Go: page, merge, cursor
    Go: page, merge, cursorgo
    // Page returns up to n post IDs older than the cursor, newest first. The cursor is the last ID of
    // the previous page (0 for the first page). Sources are the reader's timeline plus the own list of
    // every pulled account the reader follows; one pipelined round trip reads them all.
    func (s *Service) Page(ctx context.Context, reader, cursor int64, n int) ([]Entry, error) {
      pulled, err := s.pulled(ctx, reader)
      if err != nil {
        return nil, err
      }
      keys := []string{s.timeline(reader)}
      for _, a := range pulled {
        keys = append(keys, s.authored(a))
      }
      upTo := upTo(cursor)
      cmds := make([]*redis.StringSliceCmd, len(keys))
      _, err = s.RDB.Pipelined(ctx, func(p redis.Pipeliner) error {
        for i, k := range keys {
          cmds[i] = p.ZRangeArgs(ctx, newest(k, upTo, 0, n))
        }
        return nil
      })
      if err != nil {
        return nil, fmt.Errorf("read feed of %d: %w", reader, err)
      }
      var merged []Entry
      for i, c := range cmds {
        ids, err := below(c.Val(), cursor, n)
        if err != nil {
          return nil, err
        }
        if len(ids) < n && len(c.Val()) == n { // IDs in the cursor's millisecond used up the read
          if ids, err = s.older(ctx, keys[i], cursor, n); err != nil {
            return nil, err
          }
        }
        merged = append(merged, ids...)
      }
      slices.SortFunc(merged, func(a, b Entry) int { return cmp.Compare(b, a) })
      merged = slices.Compact(merged)
      return merged[:min(n, len(merged))], nil
    }
    
    // newest is ZRANGE key max -inf BYSCORE REV LIMIT offset n: the newest entries at or below max.
    // Before Redis 6.2 the same command was ZREVRANGEBYSCORE.
    func newest(key, maxScore string, offset int64, n int) redis.ZRangeArgs {
      return redis.ZRangeArgs{Key: key, Start: maxScore, Stop: "-inf", ByScore: true, Rev: true, Offset: offset, Count: int64(n)}
    }
    
    // upTo is the highest score a page may read: the cursor's millisecond, inclusive, because other
    // posts of that millisecond may sort below the cursor.
    func upTo(cursor int64) string {
      if cursor == 0 {
        return "+inf"
      }
      return strconv.FormatFloat(score(cursor), 'f', 0, 64)
    }
    
    // below keeps the members whose ID is lower than the cursor, up to n.
    func below(members []string, cursor int64, n int) ([]Entry, error) {
      var out []Entry
      for _, m := range members {
        id, err := strconv.ParseInt(m, 10, 64)
        if err != nil {
          return nil, fmt.Errorf("timeline member %q: %w", m, err)
        }
        if (cursor == 0 || id < cursor) && len(out) < n {
          out = append(out, id)
        }
      }
      return out, nil
    }
    
    // older reads one sorted set page by page from the cursor's millisecond until it has n IDs below
    // the cursor. Page needs it only when one millisecond holds n posts or more.
    func (s *Service) older(ctx context.Context, key string, cursor int64, n int) ([]Entry, error) {
      var out []Entry
      for offset := int64(0); len(out) < n; {
        got, err := s.RDB.ZRangeArgs(ctx, newest(key, upTo(cursor), offset, n)).Result()
        if err != nil {
          return nil, fmt.Errorf("read %s: %w", key, err)
        }
        ids, err := below(got, cursor, n-len(out))
        if err != nil {
          return nil, err
        }
        out = append(out, ids...)
        if len(got) < n {
          break
        }
        offset += int64(len(got))
      }
      return out, nil
    }
    
    choicestatuswhy
    Sorted set, score = ms, member = IDApprovedRange by time, exact scores, ties broken by ID.
    Score = 64-bit IDNot approvedA double holds 53 bits. Low bits are lost.
    List with LPUSH and LTRIMStrict append orderA late or out-of-order post cannot take its place by time.
    Offset pages (ZRANGE by rank)Not approved4 repeats after 4 new posts, in the lab.
    Cursor pages (last ID seen)Approved0 repeats, no gap.
    Cap at 128ApprovedListpack: 31.4 B per entry.
    Cap at 800Deep scrolling is commonSkip list: 101.9 B per entry.

    Recorded: 4 posts arrive between page 1 and page 2, page size 10. The lab also checks that a hybrid feed returns exactly the pages of a pure push feed, at page sizes 1, 3 and 7.

    The score is the post's millisecond and the member is the post ID. A page reads backwards from the cursor's millisecond and skips IDs at or above the cursor.

    K

    Deep dive: deletes, privacy, inactive users

    changes that must reach timelines
    changeat oncelater
    Post deleteddeleted_at set; hydrate skips itZREM from every follower's timeline
    UnfollowFilter that author out of the pageRemove their entries from the timeline
    BlockFilter at read time, both waysRemove entries
    Account goes privateFilter for non-followersNothing: timelines hold followers only
    User inactive 30 daysFan-out skips themRebuild the timeline on return

    Anything that hides a post is a filter at read time first. Cleaning the timelines is a background job that can lag safely.

    L

    Deep dive: ranking

    chronological, or scored
    methodstatus
    Newest first from the timelineThe baseline
    Candidates from the timeline, then a scoring model, top 20Approved
    Snapshot the ranked IDs per session; the cursor is a positionApproved
    Rank on every page requestNot approved
    Score inside the fan-outFeatures known at post time
    • Ranking every page again reorders posts the reader already saw. A snapshot keeps pages stable.
    • Scoring needs features: likes, replies, how close the reader is to the author. A stream job keeps them current (B7).

    I build the chronological feed first. Ranking reuses it as the candidate source and adds a scoring step.

    M

    Failure cases

    what breaks, and why the feed stays right
    eventresultwhy it is safesaved by
    A fan-out worker crashes mid-postSome followers have the entry, some do not.The event is not acked, so another worker runs it again. A second ZADD of the same member changes nothing.Idempotent ZADD
    The post commits, the event relay is downFan-out is late.The event waits in the outbox table and goes out when the relay returns.Outbox
    A Redis node fails over and loses recent writesSome timelines miss recent IDs.Timelines are a cache. A rebuild from Postgres restores them; posts are never lost.Rebuild
    A timeline key is evictedThe read finds no key.Rebuild on the read path, then serve.Rebuild
    An account with 100 M followers postsPush would queue 100 M writes.The account is above the threshold: no writes; readers pull its list.Hybrid
    The fan-out queue lags by minutesFollowers see posts late.Eventual consistency is in the requirements. Alert on lag in seconds; add workers up to the partition count.Lag alert
    A deleted post is still in timelinesThe ID is read.Hydrate skips deleted rows, so it is never shown.deleted_at
    A post arrives late, out of orderIts ID is older than newer posts.The score places it by time, not by arrival.Sorted set
    N

    Scale ladder

    start simple; climb only on a signal
    Each step adds one component1Postgres, join2+ timeline table3+ Redis timelines4+ hybrid, active only5+ shards, regionsmore load →
    Capacity against this prompt's demand1001k10k100k1MDemand: feed loads: 57,870 operations per secondDemand: feed loads57,870Demand: writes, active: 92,663 operations per secondDemand: writes, active92,663Postgres: join feed (D3): 199 operations per secondPostgres: join feed (D3)199Postgres: timeline (D3): 29,703 operations per secondPostgres: timeline (D3)29,703Postgres: hydrate 20: 25,240 operations per secondPostgres: hydrate 2025,240Redis: feed page, push: 14,015 operations per secondRedis: feed page, push14,015Redis: fan-out entries: 190,916 operations per secondRedis: fan-out entries190,916operations per second, log scale
    stepaddit handlesmove up when you see
    1One Postgres, feed by a join over follows and posts (D3).About 200 feed reads a second at 1,000 follows, in the lab.Feed latency grows with the number of follows.
    2A timeline table, written by fan-out on write.About 30,000 reads a second, flat as follows grow.Fan-out writes take most of the primary's write budget.
    3Redis timelines, workers fed by an event log, hydrate from replicas.About 14,000 feed pages and 191,000 fan-out entries a second on one Redis.One post takes minutes to fan out; memory grows with inactive users.
    4Hybrid and active-only fan-out.The worst post drops to the threshold; writes and memory fall by the inactive share.Memory or write rate passes one node; one region adds latency far away.
    5Redis Cluster by user, Postgres shards by author, regional read copies (X2).Add nodes as users grow. This prompt needs about 2.0 TB of timelines and 93,000 writes a second.Top of the ladder.

    Demand from panel C. Capacity measured in the lab with 8 clients on one shared laptop, median of 3 runs. Repeat runs varied by up to 2×, so read these as orders of magnitude. The feed page includes a Postgres lookup of followed large accounts.

    I start with one Postgres and a join, add a timeline table, then move timelines to Redis. I pull large accounts and shard last.

    O

    Follow-ups

    what the interviewer asks next
    questionanswerdeeper
    How do you make IDs that sort by time on many machines?Use a Snowflake-style ID: milliseconds, a node number and a sequence. Each node makes IDs alone, and a clock step backwards must make the node wait.X4
    How do you shard timelines and posts?Timelines by user in Redis Cluster; posts by author_id with the ID as the order key. A feed read touches one timeline shard plus the hydrate fan-in.X2
    A post goes viral: millions read it a second.Cache the hydrated post in memory near the service and on the CDN for media. Keep like counts as sharded counters merged every second.B4
    The author must see their own post at once. How?Merge the author's own list into their feed at read time. That gives read-your-writes without waiting for the fan-out.X3
    The fan-out queue lags. What do you check?Lag in seconds per partition, the slowest worker, and one hot author on one partition. Add workers up to the partition count.B6
    Users want to search posts.Index posts from the same event log into a search index. The feed and search read the same events at their own pace.B8
    How do images load fast worldwide?Upload once to object storage with a presigned URL; serve resized copies from CDN edges with long cache lifetimes.B5
    P

    Drill

    predict, then reveal

    0 of 9 known

    1. An account with 100 million followers posts. What does pure push cost, and what does the hybrid do instead?

    2. Why does the timeline hold post IDs and not post bodies?

    3. Why is the sorted-set score the millisecond and not the 64-bit post ID?

    4. A reader opens page 2 after 4 new posts arrived. What does an offset page show?

    5. Why cap each timeline at 128 entries?

    6. A user returns after three months away. Fan-out skipped them. What do they see?

    7. A post is deleted. How fast does it leave every feed?

    8. Where is the feed eventually consistent, and why is that acceptable?

    9. How do you page through a ranked feed?

    Q

    Numbers to say

    measured, derived or simulated
    demand
    About 58,000 feed loads and 1,200 posts a second.
    push all
    20 B timeline writes a day, about 231,000 a second.
    worst post
    16,499 writes with push; 3,846 with a 10k threshold (simulated).
    fan-out
    About 106,000 entries a second per worker; 100 k followers in 0.9 s.
    memory
    31.4 B per entry up to 128; 101.9 B as a skip list.
    reads
    About 14,000 pages a second from one timeline; 2,500 merging 50 lists.
    scores
    A float64 is exact to 2^53; a 64-bit ID is not.

    Postgres 16 and Redis 8 on an 8-core laptop shared with other runs, 8 clients. Simulation: 100,000 users, seed 5.