Design a News Feed System
This page is one interview loop in three rounds. All three rounds design the same system. Each round opens with the interviewer raising the scope, and the design from the round before has to evolve to meet it.
| Round 1: Mid-level | Round 2: Senior | Round 3: Architect | |
|---|---|---|---|
| Story | A growing app: people follow people and see a chronological feed | 300M daily users, celebrities with tens of millions of followers, a ranked feed | Worldwide, private by default, and under attack from spammers |
| Level (Amazon) | SDE II (L5) | Senior SDE (L6) | Principal (L7) |
| Traffic | 1M DAU; ~35 posts/s and ~175 feed reads/s at peak | 300M DAU; ~3,500 posts/s and ~52K feed reads/s at peak | ~1B DAU in 4 regions; ~174K feed reads/s at peak worldwide |
| Survives | Losing a cache node or an AZ | Losing an AZ; a celebrity post storm | Losing a region |
| Targets | 99.9%; feed P99 < 500 ms; new posts visible within ~1 min | 99.99%; feed P99 < 200 ms; 99% of followers see a post within 5 s | 99.99% per region; privacy changes take effect in seconds |
| Reading time | ~35 min | ~40 min | ~45 min |
You can start at any round. Rounds 2 and 3 open with a "Where we left off" summary that catches you up.
Loop Opener: What Is a News Feed?
You Already Use One: a Mailbox of Friends' Updates
Open any social app and the first screen is your home feed: posts from the people you follow, newest first (or "best" first). Think of it as a mailbox. Every time someone you follow posts, a copy of the envelope lands in your mailbox. When you open the app, you read the top of the pile.
Three operations make up the whole product:
| You do | The system does |
|---|---|
| Post "Just shipped v2!" with a photo | Stores the post, and makes it show up for everyone who follows you |
| Follow someone | Records one edge in a graph: you → them |
| Open the app and scroll | Returns the next 20 posts from the people you follow, without repeating any |
What Makes It Hard
Two numbers pull against each other:
- One post may need to appear in millions of mailboxes. A singer with 60 million followers posts once, and 60 million feeds should change.
- Millions of people open their mailbox at the same moment. Reads outnumber posts by more than ten to one, and every read wants an answer in a fraction of a second.
There are only two places to do the work. We can do it when someone posts (copy the post into every follower's mailbox now, so reading is cheap later), or when someone reads (collect the posts from everyone they follow at the moment they open the app). Every component in this loop moves work from one side to the other.
The Question the Whole Loop Answers
Do we do the work when someone posts, or when someone reads, and who decides for each post?
The answer gets sharper every round:
- Round 1: do it when someone posts. We precompute every user's feed, and a read becomes one cheap lookup.
- Round 2: that breaks for celebrities, for users who come back after a month, and for deleted posts. So it becomes a per-author decision, and ranking arrives.
- Round 3: the hard part is no longer speed. It is privacy, deletion, geography and abuse, where "precomputed" must never mean "allowed".
Round 1 · Mid-level · "A Feed for a Growing Social App"
~35 min · SDE II (L5) · 1 region, 3 AZs · 1M DAU · ~175 feed reads/s peak · 99.9%
R1.1 Establish Design Scope
The interviewer says: "Design the news feed for our social app." Before we draw anything, we ask questions, and we say out loud what each answer changes.
| We ask | Interviewer answers | What it changes in the design |
|---|---|---|
| Chronological or ranked? | Chronological for now: newest first. | The order is fixed by post time. We don't need a ranking model yet, and "newest first" is something a sorted list can hold directly. |
| How many accounts does a user follow on average? | About 200. | A read that collects posts at read time has to touch about 200 accounts. That is our first cost to beat. |
| Text, images, video? | Text and images. No video yet. | Images are big and never change after upload, so they belong in object storage behind a CDN, not in our database. |
| How fresh must the feed be? | A new post should show up for followers within about a minute. | We don't have to update followers' feeds inside the post request. We can do it in the background. |
| Can a user see the same post twice while scrolling? | No. And they shouldn't skip any either. | Paging must be stable while new posts keep arriving at the top. Offsets won't do (step 1.3). |
| What's the largest follower count today? | About 10,000. | One post causes at most 10,000 feed updates. Round 1 has no celebrities. |
| How many users, how many posts? | About 1M daily active users. Early users are keen: about one post each per day. | Small numbers. We derive the traffic in R1.7. |
Out of scope for this round:
- Celebrities with millions of followers.
- Ranking by relevance.
- Privacy controls (friends-only posts, blocks).
- Edits and deletes showing up in feeds. Posts can be deleted, but we don't yet promise how fast they vanish from other people's feeds.
The interviewer will widen this scope later. Write your out-of-scope list where you can see it: in a multi-round loop, some of it comes back.
R1.2 Functional Requirements, Derived Step by Step
We read the problem one phrase at a time and turn each phrase into a requirement:
| Phrase from the problem | Requirement |
|---|---|
| "Share an update" | post: store a post with text and an optional image, owned by its author |
| "See updates from people I follow" | feed: return posts from the accounts I follow, newest first |
| "Follow someone" | follow and unfollow: add or remove one edge in the follow graph |
| "Keep scrolling" | Pagination: each call returns the next page, with no repeats and no gaps |
Not yet: ranking, edits and deletes that disappear from feeds, and celebrities. The interviewer will bring them back.
R1.3 Non-Functional Requirements: the Questions
Numbers come in R1.7. For now, the questions and why each matters:
- Read latency. The feed is the first screen of the app. If it is slow, the whole app feels slow.
- Freshness. How long between a post and a follower seeing it? "About a minute" lets us do work in the background.
- Availability. If the feed is down, the app is empty. But a feed that is a few seconds stale is still useful, so we can prefer "slightly stale" over "error".
- Read-heavy scale. Each user reads several times a day and posts about once. Reads outnumber posts, so we should make reads cheap even if posts cost more.
R1.4 The API
Three endpoints, all behind the app's normal login (a bearer token on every call).
1. Upload the image, then create the post. Images never pass through our servers. The client asks for a pre-signed URL: a short-lived S3 upload link, signed with our credentials, that allows exactly one PUT of one object.
httpPOST /v1/media/upload-url HTTP/1.1 Host: api.example-social.com Authorization: Bearer <token> Content-Type: application/json { "content_type": "image/webp", "size_bytes": 184320 }
httpHTTP/1.1 200 OK Content-Type: application/json { "media_id": "m_7Qz2", "upload_url": "https://media-uploads.s3.amazonaws.com/u/401/m_7Qz2.webp?X-Amz-Expires=300&X-Amz-Signature=...", "expires_in_s": 300 }
The client PUTs the image to upload_url, then creates the post:
httpPOST /v1/posts HTTP/1.1 Host: api.example-social.com Authorization: Bearer <token> Idempotency-Key: 7b8e1f20-945a-497b-95eb-36b17c5b6102 Content-Type: application/json { "text": "Just shipped v2!", "media_ids": ["m_7Qz2"] }
httpHTTP/1.1 201 Created Content-Type: application/json { "post_id": "97301141913751557", "author_id": "401", "created_at": "2026-09-26T12:00:00.000Z", "text": "Just shipped v2!", "media": [{ "url": "https://cdn.example-social.com/u/401/m_7Qz2.webp", "width": 1080, "height": 1080 }] }
The Idempotency-Key lets a client retry a timed-out request without creating the post twice. IDs are strings in JSON, because a 64-bit ID is bigger than JavaScript can hold exactly (see the unique ID generator loop).
2. Read the feed.
httpGET /v1/feed?limit=20&cursor=eyJ2IjoxLCJzIjoyMzE5ODQwMDAwMCwicCI6Ijk3MzAxMTQxOTEzNzUxNTU3In0.k3Jd9w HTTP/1.1 Host: api.example-social.com Authorization: Bearer <token>
httpHTTP/1.1 200 OK Content-Type: application/json { "items": [ { "post_id": "97301141913751557", "author": { "user_id": "401", "name": "Alex", "avatar_url": "https://cdn.example-social.com/a/401.webp" }, "created_at": "2026-09-26T12:00:00.000Z", "text": "Just shipped v2!", "media": [{ "url": "https://cdn.example-social.com/u/401/m_7Qz2.webp", "width": 1080, "height": 1080 }] } ], "next_cursor": "eyJ2IjoxLCJzIjoyMzE5ODM5OTg3NiwicCI6Ijk3MzAxMTQxMzk2OTY0MzIwIn0.Qm8aZ1", "has_more": true }
The cursor is opaque: the client stores it and sends it back, and must never build or edit one. That leaves us free to change what's inside (step 1.3 puts a position and a signature in it) without breaking any app version.
3. Follow and unfollow.
httpPOST /v1/users/802/follow HTTP/1.1 Authorization: Bearer <token>
httpHTTP/1.1 200 OK Content-Type: application/json { "user_id": "802", "following": true, "followed_at": "2026-09-26T12:01:10.000Z" }
DELETE /v1/users/802/follow undoes it. Both are idempotent: following someone you already follow returns the same 200.
| Status | When |
|---|---|
201 Created | A post was stored |
400 Bad Request | A cursor fails its signature check (the client starts again from page 1) |
401 Unauthorized | Missing or expired token |
429 Too Many Requests | A client is over its rate limit (with Retry-After) |
503 Service Unavailable | A dependency is down and we have no stale answer to give |
Recap
- Three operations: post (text + image via a pre-signed S3 upload), follow, and read the feed 20 posts at a time.
- Chronological order; no repeats and no gaps while scrolling; a cursor the client can't read.
- Freshness of about a minute is fine.
- Reads outnumber posts, so reads should be the cheap side.
Let's build it, starting with the simplest thing that works.
R1.5 Design Evolution: From "Query on Read" to Precomputed Timelines
Every step below follows the same pattern: a problem, your turn to think, the answer, and what the answer costs us. The cost is always the next problem.
Step 1.0: The Baseline
Store posts and follow edges in a database. On each feed read, look up everyone I follow, query each one's recent posts, merge them by time, and return the top 20.
Synthesizing vector architecture diagram...
What's good about it: it is simple, it is always fresh (a post is visible the instant it's stored), and posting costs one write. This is called fanout on read: the work of spreading a post to its readers happens when they read.
What it costs us: every feed read is about 200 queries plus a merge, and the read is only as fast as the slowest of those 200 queries.
Step 1.1: Feeds Take Seconds to Load
The problem: at the evening peak, feed reads take one to three seconds. Each read runs about 200 queries in parallel, and one slow query holds up the whole page. What would you do?
Primitive: Distributed Cache Patterns & Eviction
Step 1.2: Memory Explodes, and Edits Never Show
The problem: a teammate stored the whole post (text, author name, image URL, like count) in each timeline entry, "so a read needs no second lookup". Cache memory is five times the plan, and when an author fixes a typo, their followers keep seeing the old text. What would you do?
The post store in DynamoDB. We key posts by their own ID, because hydration only knows IDs, and BatchGetItem needs the table's primary key (it cannot read through a secondary index):
| Item | Partition key PK | Sort key SK | Other attributes |
|---|---|---|---|
| Post | POST#<post_id> | METADATA | author_id, text, media (list of URLs, sizes), created_at, status (ACTIVE or DELETED) |
Primitive: Distributed Cache Patterns & Eviction
Step 1.3: Page 2 Repeats Posts From Page 1
The problem: a user reads page 1 (entries 0–19). While they read, three new posts arrive at the top of their timeline. Page 2 asks for "entries 20–39" and shows the last three posts of page 1 again. What would you do?
Step 1.4: Posting Blocks on Hundreds of Timeline Writes
The problem: our first version fanned out inside the POST /v1/posts request: store the post, look up the followers, write to each timeline, then return. An author with 8,000 followers waits two seconds for "posted", and if the request times out halfway, some followers get the post and some never will.
What would you do?
textfanout_worker, for each post-created event from the stream: post = event.new_item score = time part of post.post_id # step 1.6 for each page of followers(post.author_id): # Query on the follow table's index, 1,000 at a time for each follower in page: in the cache, as one atomic step: if timeline tl:<follower> exists: add (score, post.post_id) trim to the newest 800 # step 1.5 checkpoint the stream position # only after the cache confirmed every write
Checkpointing only after the writes are confirmed makes the worker at-least-once: if it crashes mid-post, the next worker replays that post from the last checkpoint, and the duplicate adds are harmless.
Primitive: Message Queues vs Event Streams · Primitive: Change Data Capture & the Outbox Pattern · Drill: CDC outbox dual-write drift
Step 1.5: Timelines Grow Forever
The problem: a user who follows 200 active accounts receives about 200 new entries a day. After a year their timeline holds 73,000 entries, almost all of which they will never scroll to. Users who stopped using the app months ago still get every new post, and their timelines take memory too. What would you do?
Step 1.6: A Retry Inserted an Older Post Above a Newer One
The problem: an author posts twice, a second apart. The event for the first post hits a worker that crashes, and is replayed ten seconds later, after the second post was already fanned out. In a list that appends in arrival order, the older post now sits above the newer one. What would you do?
Primitive: Distributed Unique ID Generators · Loop: Design a Distributed Unique ID Generator
Round 1 Step Summary
| Step | Problem | Component | What it costs us |
|---|---|---|---|
| 1.0 | (baseline) | Query ~200 accounts on every read and merge | Slow reads, bound by the slowest query |
| 1.1 | Feeds take seconds | Fanout on write into per-user sorted-set timelines | N writes per post; memory |
| 1.2 | Memory explodes; edits never show | IDs-only timelines; batch hydration from the post store | A second read per page |
| 1.3 | Page 2 repeats page 1 | Signed cursor on (score, post ID), inclusive boundary + tie filter | Cursor versioning and key rotation |
| 1.4 | Posting blocks on fanout | Post write → Kinesis Data Streams for DynamoDB → fanout workers | A short delay; a stream and workers to run |
| 1.5 | Timelines grow forever | Trim to 800; 7-day expiry refreshed on read; add only if the timeline exists | Fallbacks for deep scrolls and returning users |
| 1.6 | Late events land out of order | Score = the time part of the Snowflake ID | Order only as good as ID clocks |
Two costs stay open for Round 2: a returning user has no timeline, and the whole design assumes nobody has millions of followers.
R1.6 Architecture v1
Now the concepts get AWS names.
Synthesizing vector architecture diagram...
Follow the two paths. Writing: the post service stores the post and returns; the table's change stream carries it to the fanout workers, which read the author's followers and add the post ID to each existing timeline. Reading: the feed service does one range read on the reader's timeline and one batch read of 20 posts. Images never touch our servers: they go up to S3 through a pre-signed URL and come down through CloudFront.
The pieces:
- ECS services in three AZs behind an Application Load Balancer. The fanout workers use the Kinesis Client Library (KCL), which assigns each stream shard to one worker and keeps its progress (the checkpoint) in a small DynamoDB lease table.
- DynamoDB, on-demand capacity: one table for posts, follow edges and profiles, replicated across three AZs by the service.
- ElastiCache (Valkey or Redis OSS), cluster mode: timelines spread over shards by key; each shard has a primary and a replica in another AZ, with automatic failover.
- S3 + CloudFront for images.
The table design (one table, SocialCoreTable, and one global secondary index, GSI1; a GSI is a second copy of chosen items, keyed differently, that DynamoDB keeps in sync asynchronously):
| Item | PK | SK | GSI1PK | GSI1SK | Other attributes |
|---|---|---|---|---|---|
| Profile | USER#<user_id> | PROFILE | – | – | name, avatar_url, follower_count, following_count |
| Post | POST#<post_id> | METADATA | AUTHOR#<author_id> | <post_id> | text, media, created_at, status |
| Follow edge | USER#<follower_id> | FOLLOWS#<followee_id> | FOLLOWED_BY#<followee_id> | <follower_id> | created_at |
GSI1SK is a Number attribute. Stored as a string, IDs would sort as text, and our IDs grow from 17 to 18 digits around 2026-10-03 ( ms ≈ 276 days after the ID epoch), so every newer 18-digit ID would sort below the older 17-digit ones. (A zero-padded 20-digit string would also work.)
- "Who do I follow?" →
Query PK = USER#me, SK begins_with FOLLOWS#. - "Who follows X?" (fanout) →
Query GSI1 where GSI1PK = FOLLOWED_BY#X, 1,000 at a time. - "X's latest posts" (fallbacks) →
Query GSI1 where GSI1PK = AUTHOR#X, newest first. - "Posts by ID" (hydration) →
BatchGetItemonPK = POST#<id>.
The timeline layout (one sorted set per user):
| Part | Value |
|---|---|
| Key | tl:<user_id> |
| Member | post ID, as a decimal string |
| Score | milliseconds since the ID epoch (the ID's time part) |
| Size | at most 800 members |
| Expiry | 7 days, refreshed on each feed read |
Trace 1: a post is created and fanned out.
Synthesizing vector architecture diagram...
The author's request ends at the 201. Everything after it happens in the background, and a crash anywhere below the line replays from the last checkpoint.
Trace 2: a feed read.
Synthesizing vector architecture diagram...
Two network round trips to data stores, each a single batched call. If tl:802 doesn't exist (a returning user), the feed service falls back to the baseline read once and builds the timeline as it goes.
R1.7 Numbers
Traffic (assumptions: each user opens the feed 5 times a day, and posts once a day; peak is 3× average)
| Quantity | Math | Value |
|---|---|---|
| Posts per day | 1M users × 1 | 1M |
| Posts/s, average | 1,000,000 ÷ 86,400 | ≈ 11.6 |
| Posts/s, peak | 11.6 × 3 | ≈ 35 |
| Feed reads per day | 1M × 5 | 5M |
| Feed reads/s, average | 5,000,000 ÷ 86,400 | ≈ 58 |
| Feed reads/s, peak | 58 × 3 | ≈ 174 |
Fanout writes (average 200 followers per post):
The largest account has 10,000 followers, so the biggest single post is 10,000 writes. A worker that sends writes to the cache in pipelined batches does tens of thousands per second (an assumption we confirm by load test), so even that post is spread in well under a second.
What the baseline would have cost. At 174 reads/s and 200 queries each, the query-on-read design sends about 35,000 queries a second to the database at peak. The timeline design sends 174 range reads to the cache and 174 batch reads (of 20 items) to DynamoDB.
Timeline memory.
| Quantity | Math | Value |
|---|---|---|
| Users with a timeline | users who read within 7 days: assume 1.5 × DAU | 1.5M |
| Entries per timeline | the trim limit (most active users reach it within a week) | 800 |
| Bytes per entry | an estimate for the cache's default sorted-set layout; Round 2 derives it | ~120 B |
| Raw data | 1.5M × 800 × 120 B | 144 GB |
| With ~25% for fragmentation and overhead | 144 × 1.25 | ≈ 180 GB |
A cache.r7g.4xlarge node has 105.81 GiB of memory. ElastiCache reserves 25% of it by default for non-data use (reserved-memory-percent), which leaves about 79 GiB ≈ 85 GB, and we plan to fill that to 80%, about 68 GB per shard. , so 3 shards, each with a primary and a replica in another AZ: 6 nodes.
For comparison, full posts in the timelines (step 1.2's wrong answer) would add ~550 bytes of post to every entry: about B ≈ 800 GB raw. Five times the memory, before a single edit goes wrong.
Post storage. A post item is about 550 bytes (text, media URLs, IDs, timestamps, attribute names). MB a day, about 200 GB a year, plus the same again in the GSI copy of the key attributes.
Media. Assume 20% of posts carry one ~200 KB image: GB a day into S3, ~1.2 TB a month.
Media delivery. Assume each feed page loads 4 images (20 posts × 20%) at a ~60 KB phone-sized version: TB a day, ≈ 36.5 TB a month out of CloudFront.
Availability budget. 99.9% over a 30.4-day month allows minutes of downtime.
Monthly cost (us-east-1 on-demand list prices, 730 hours a month; rounded)
| Item | Math | Monthly |
|---|---|---|
ElastiCache, 6 × cache.r7g.4xlarge | 6 × $1.745/h × 730 h | ≈ $7,640 |
| CloudFront data out | 10 TB × $0.085/GB + 26.5 TB × $0.080/GB | ≈ $2,970 |
| CloudFront requests | 5M pages × 4 images × 30.4 ≈ 608M HTTPS requests × $0.01 per 10,000 | ≈ $610 |
| DynamoDB reads | 58 reads/s × 20 items × 0.5 RRU (≤ 4 KB, eventually consistent) ≈ 580 RRU/s ≈ 1.5B RRU × $0.125 per million | ≈ $190 |
| DynamoDB writes | ~2 WRU per post (item + GSI) and per follow change, ~3M/day ≈ 91M WRU × $0.625 per million | ≈ $60 |
| DynamoDB storage | grows ~20 GB a month (items + GSI); a year in: ~250 GB × $0.25 | ≈ $60 |
| S3 media | grows ~1.2 TB a month; a year in: ~14 TB × $0.023 | ≈ $330 |
| ECS on Fargate (Graviton) | API: 3 tasks × (2 vCPU, 4 GB); workers: 3 × (1 vCPU, 2 GB) ≈ $0.36/h | ≈ $260 |
| Kinesis | 2 shards × $0.015/h × 730 h, plus PUT units | ≈ $25 |
| ALB, CloudWatch, misc. | ≈ $150 | |
| Total | ≈ $12,300/month |
Say the headline in the interview: the timeline cache is already the biggest line. We are trading memory for read speed on purpose. Round 2 will have to look hard at that trade.
R1.8 Trade-Offs
| Fanout on read (baseline) | Fanout on write (our choice) | |
|---|---|---|
| Work per post | 1 write | 1 write + N timeline writes (N = followers) |
| Work per read | ~200 queries + merge | 1 range read + 1 batch read |
| Read latency | The slowest of ~200 queries | Two fast calls |
| Freshness | Instant | A moment's delay (the stream + workers) |
| Memory | None extra | A timeline per active user |
| Where it breaks | Many reads, many follows | Authors with huge follower counts (Round 2) |
Cache vs database for timelines. We could keep timelines in DynamoDB instead: durable, no memory bill. But every fanout write would be a billed write request (2,300 a second on average, ~6 billion a month at $0.625 per million ≈ $3,800), and the feed read would be a Query instead of an in-memory range read. We keep them in the cache because a timeline is derived data: it can always be rebuilt from posts and follows, so losing it costs time, not correctness. That one fact makes the cache safe.
Chronological vs ranked. Chronological is simple to page, easy to explain and cheap. Ranked feeds show "the best" first, which products usually want, but they need a model, features and a latency budget. Round 2 adds ranking, on top of the timeline, not instead of it.
R1.9 Failure Modes
| Failure | What you'd see | How the design responds |
|---|---|---|
| A fanout backlog (workers slow or crashed) | Kinesis iterator age climbs; new posts reach followers late | Feeds still load, just a little stale. The stream keeps records (24 hours by default), so nothing is lost. We add workers; the KCL hands shards to them. |
| A cache primary fails | A few seconds of errors on that shard | ElastiCache promotes the replica in another AZ. Writes acknowledged by the old primary but not yet copied to the replica are lost (replication is asynchronous). Those posts are missing from a few timelines until they are replayed or pushed out by newer posts; posts themselves are safe in DynamoDB. |
| A whole shard is lost (primary and replica) | Timelines on that shard are gone | Timelines are derived data. Readers of those timelines fall back to the query-on-read path, which builds each timeline as they read. Slow for a while, never wrong. |
| A hot partition in the follow table | Throttling on the index of a popular account while many people follow it at once | A single index partition takes about 1,000 writes a second. At 10,000 followers in Round 1 this is far away. Round 2's celebrities make it real. |
| An AZ is lost | A third of the ECS tasks and some cache primaries gone | Tasks run in all three AZs and the ALB routes around the lost one; cache replicas in other AZs are promoted; DynamoDB is multi-AZ. |
Drill: Message queue order pipeline
R1.10 Pillar Check
| Pillar | What Round 1 covers |
|---|---|
| Reliability | Timelines are derived data and can always be rebuilt from DynamoDB; services, cache replicas and data across three AZs; the stream buffers a fanout backlog REL 10 · REL 11 |
| Performance Efficiency | A feed read is one range read plus one batch read; timelines hold IDs only; fanout moved off the request path PERF 1 · PERF 3 |
| Security | Auth on every call; uploads through short-lived pre-signed URLs, so the app never holds AWS credentials; signed cursors bound to the user SEC 3 |
| Cost Optimization | About $12,300/month; IDs-only timelines at ~120 B an entry instead of ~670 B; trimming to 800 and expiring inactive users COST 6 |
| Operational Excellence | Light this round: alarms on fanout lag (Kinesis iterator age) and feed P99 latency OPS 8 |
| Sustainability | Skipped this round. |
R1.11 Round 1 Rubric and Follow-Ups
What a strong mid-level (L5) answer shows
- Starts from query-on-read, names its cost (about 200 queries per read), and moves the work to write time with a reason.
- Stores IDs, not posts, in timelines, and hydrates in one batch read.
- Pages with a cursor on (score, post ID) and explains why offsets repeat and skip.
- Takes fanout out of the post request, and knows why a second write after the database is risky.
- Bounds timelines by depth and by inactivity, and knows the timeline can be rebuilt.
- Derives the traffic, fanout writes and memory, and sees that memory is the big line.
Follow-up questions
-
"A user follows a new account. Does their timeline get that account's old posts?" Answer: not through fanout, which only carries new posts. The follow service can queue a small backfill: read the new followee's latest 20 posts and add them to the follower's timeline (the same "add, then trim to 800" step). It's asynchronous, so the first refresh after following may not show them yet.
-
"Why not a message queue like SQS instead of a stream?" Answer: SQS would work for fanout jobs. We chose a stream because the event source is the table's own change log (no dual write), because a stream can be replayed from a position after a bug, and because more consumers (search, notifications) can read the same events later. A queue deletes each message once it's handled.
-
"What happens if the cache is completely empty after a big failure?" Answer: every read falls back to query-on-read and rebuilds that user's timeline. That is roughly the baseline's load, about 35,000 queries a second at peak in Round 1, which DynamoDB on-demand can absorb for a while. We would also slow the fanout (there's little point writing into timelines that don't exist yet, and the "add if exists" rule already skips them).
Interview gotchas from this round's wrong answers
| Gotcha | Why it's wrong |
|---|---|
| "Store the whole post in the timeline" | ~5× the memory, and every edit or like count must be rewritten into every copy. |
| "Page with offsets" | New posts at the top shift every offset: repeats and skips. |
| "Fan out inside the post request" | The author waits on N writes, and a timeout leaves some followers without the post forever. |
| "Use the post ID as the sorted-set score" | Scores are floating-point numbers, exact only up to ; 64-bit IDs lose precision. Use the ID's time part. |
| "Refresh the timeline's expiry on every fanout write" | Inactive users' timelines then never expire, as long as the people they follow keep posting. |
Round 2 · Senior · "300M Daily Users and Celebrities"
~40 min · Senior SDE (L6) · 1 region, 3 AZs · 300M DAU · ~52K feed reads/s peak · 99.99% · feed P99 < 200 ms
R2.0 Where We Left Off
This is what the candidate says aloud in the first 60 seconds of Round 2. If you're starting here, it's everything you need from Round 1.
Round 1 in 60 seconds. "We built a chronological feed for 1M daily users: about 35 posts and 175 feed reads a second at peak, one region, three AZs, 99.9%. We moved the work from read time to write time. Posts live once in DynamoDB, keyed by post ID. Every post change flows through Kinesis Data Streams for DynamoDB to fanout workers, which read the author's followers from a GSI and add the post ID to each follower's timeline: a sorted set in ElastiCache, scored by the time part of the Snowflake post ID, trimmed to 800 entries, and expiring 7 days after its owner last read it. A feed read is one range read plus one
BatchGetItemof 20 posts. Paging uses a signed cursor on (score, post ID). Images go to S3 through pre-signed URLs and out through CloudFront. About $12,300 a month, and the timeline cache is the biggest line. Four costs are still open: a celebrity post would mean millions of writes; returning users have no timeline; a deleted post can linger in timelines; and the feed is chronological only."
Architecture v1, compact
textapp ──► ALB ──► post service ──► DynamoDB (POST#id) ──change stream──► Kinesis ──► fanout workers (KCL) │ Query GSI1 FOLLOWED_BY#author ▼ ElastiCache: tl:<user> sorted set, ≤ 800 IDs, 7-day expiry refreshed on read app ──► ALB ──► feed service ──► 1. range read tl:<me> at/below cursor 2. BatchGetItem 20 posts app ──► S3 (pre-signed PUT); CloudFront ──► S3 for images
Round 1 step summary
| Step | Problem | Component | What it costs us |
|---|---|---|---|
| 1.1 | Feeds take seconds | Fanout on write into sorted-set timelines | N writes per post; memory |
| 1.2 | Memory, stale edits | IDs-only timelines; batch hydration | A second read per page |
| 1.3 | Page 2 repeats page 1 | Signed (score, post ID) cursor | Cursor versioning |
| 1.4 | Posting blocks on fanout | Change stream → fanout workers | A short delay |
| 1.5 | Timelines grow forever | Trim to 800; expiry refreshed on read; add only if the timeline exists | Returning users have no timeline |
| 1.6 | Late events land out of order | Score = time part of the ID | Order as good as ID clocks |
Open costs: celebrity posts, returning users, lingering deleted posts, and no ranking.
R2.1 The Scope Raise
Interviewer: "The app took off. We have 300 million daily users now, posting 100 million times a day. Some accounts have 60 million followers. Product wants a ranked feed, not a chronological one. When someone deletes a post, it has to disappear from feeds, and edits must show. People who come back after a month shouldn't stare at a spinner. We want 99.99%, and the feed's P99 under 200 milliseconds."
A scope raise is not the end of scoping. Before we fix anything, we ask back, and we say what each answer changes, just as in R1.1.
| We ask | Interviewer answers | What it changes in the design |
|---|---|---|
| How many accounts have huge followings? | About half a million accounts have more than 25,000 followers. A few hundred have tens of millions. | Pushing every post to every follower is no longer affordable for everyone. We need a per-author decision (step 2.1). |
| What happens right after a big account posts? | Their followers get a notification, and millions open the app within a couple of minutes. | Millions of reads want the same few posts at once (step 2.2). |
| How spiky is posting? | During big news, posts jump to several times the normal peak for minutes. | The fanout fleet must survive bursts without knocking over the cache (step 2.3). |
| How fresh must feeds be now? | 99% of followers should see a normal user's post within 5 seconds. | Fanout lag becomes an SLO we alarm on, not just a nice-to-have. |
| How fast must the first page load for someone back after a month? | Same P99 as anyone else. It can be a bit less complete. | We can't do a full query-on-read for them (step 2.4). |
| How fast must a delete take effect? | Gone from every feed on the next load. Edits too. | Deletes can't depend on reaching every timeline in time (step 2.5). |
| What does "ranked" mean, and is there a budget? | A model that predicts what each person will engage with. It must fit inside the 200 ms. | Ranking becomes a pipeline with a latency budget and a fallback (step 2.6). |
Scope change
| Round 1 | Round 2 | |
|---|---|---|
| Users | 1M DAU | 300M DAU |
| Posts | ~1M/day; ~35/s peak | 100M/day; ≈ 1,157/s average, ≈ 3,500/s peak |
| Feed reads | ~5M/day; ~175/s peak | 1.5B/day; ≈ 17,361/s average, ≈ 52K/s peak |
| Largest account | 10K followers | 60M followers |
| Order | Chronological | Ranked, with paging |
| Deletes and edits | No promise | Gone or updated on the next load |
| Freshness | ~1 min | 99% of followers within 5 s |
| Availability | 99.9% (43.8 min/month) | 99.99% (4.4 min/month) |
| Feed latency | P99 < 500 ms | P99 < 200 ms |
| Data | ~200 GB of posts a year | 182.6B posts over 5 years ≈ 100 TB; 16.5 TB of media a day |
The "Not yet" list from R1.2 is now mandatory: ranking, edits and deletes in feeds, and celebrities.
R2.2 What Breaks in the Round 1 Design
| Round 1 choice | What breaks at the new scope |
|---|---|
| Push every post to every follower | One post by a 60M-follower account is 60M timeline writes. Even at 50,000 writes a second per worker, that is 1,200 worker-seconds for one post, and the burst floods the cache. |
| Readers of one author each read the post store | Millions of readers ask for the same celebrity's latest posts within minutes: one partition key, far past what one DynamoDB partition serves. |
| Fanout workers run as fast as they can | A burst of posts turns into millions of cache writes a second; cache latency climbs and every feed read slows with it. |
| Returning users fall back to query-on-read | At 300M users, a slow multi-second first page for everyone who comes back is a steady stream of bad sessions and a heavy load on the database. |
| "Deleted posts are skipped if missing" | We only promised this for hydration. There is no plan for timelines that still hold the ID, or for caches that still hold the post. |
| Chronological order | The product wants ranking. A sorted-by-time list can't express "what you'll care about". |
| 1.5M timelines at ~120 B an entry | 300M users at that size is tens of terabytes of memory (R2.6). The cache stops being an affordable line. |
R2.3 New Requirements and API Additions
Ranked feed. The same endpoint, with a mode. The first call ranks a set of candidates once and stores the ranked list for this feed session; later pages read from that stored list, so paging is stable even though scores would change between calls.
httpGET /v1/feed?mode=ranked&limit=20 HTTP/1.1 Authorization: Bearer <token>
httpHTTP/1.1 200 OK Content-Type: application/json { "items": [ { "post_id": "97301141913751557", "...": "..." } ], "next_cursor": "eyJ2IjoyLCJtIjoicmFua2VkIiwic2lkIjoiZnNfOGsyUSIsIm8iOjIwfQ.Zp1xQe", "has_more": true, "ranking": "model" }
The cursor now carries a version, a mode, a session ID and an offset into the stored ranked list:
json{ "v": 2, "m": "ranked", "sid": "fs_8k2Q", "o": 20 }
An offset is safe here, unlike in step 1.3, because the stored list never changes during the session. "ranking": "chronological" tells the app when ranking fell back (step 2.6). The chronological mode and its v1 cursor still work.
Edit and delete.
httpPATCH /v1/posts/97301141913751557 HTTP/1.1 Authorization: Bearer <token> Content-Type: application/json { "text": "Just shipped v2.0.1!" }
httpDELETE /v1/posts/97301141913751557 HTTP/1.1 Authorization: Bearer <token>
Both return 200 with the post's new state ("status": "DELETED" for a delete). A delete sets a tombstone (a marker saying "this was deleted") on the post item instead of removing it at once, so every cache and timeline can recognize it. Both changes flow through the same change stream as new posts, as MODIFY records.
The celebrity registry. An internal list of accounts we don't push for, with an item per account:
| Item | PK | SK | Attributes |
|---|---|---|---|
| Registry entry | CELEB#<bucket 0–99> | USER#<user_id> | follower_count, mode (PULL), since |
The bucket (a hash of the user ID, 0–99) spreads 500K entries over 100 partition keys, so no single key gets hot when the list is rebuilt. Feed nodes never query it per request: a job writes a snapshot of all registry IDs (500K × 8 bytes ≈ 4 MB) to S3 every minute, and every feed node loads it into memory.
R2.4 Design Evolution: Hybrid Fanout, Ranking and Repair
Step 2.1: A Celebrity's Post Needs 60 Million Timeline Writes
The problem: an account with 60M followers posts. Round 1's fanout means 60M cache writes for one post, and this account posts ten times a day. What would you do? Where does the work go for posts like this?
Go deeper: the celebrity's follower list is one hot index key. Round 1's GSI puts every follower of an account under GSI1PK = FOLLOWED_BY#<id>. For a 60M-follower account that is one partition key with 60M items. We no longer read it for fanout, but two things still hurt:
- Writes. A DynamoDB partition serves at most 1,000 write units a second. When a celebrity goes viral and gains 10,000 followers a second, all those GSI writes target one key. A GSI that can't keep up throttles writes to the base table: follows of this account, and of any other account whose index key shares that partition, start failing.
- Counters. Keeping
follower_counton the profile item means 10,000 updates a second to one item, over the same per-partition limit.
The fix: write-shard the index key for large accounts. The edge's GSI1PK becomes FOLLOWED_BY#<id>#<n>, where n = hash(follower_id) mod S, and the profile stores S (1 for almost everyone, 32 for the largest accounts). Anyone who needs all followers queries the S keys in parallel. Changing S for an existing account means a backfill job that re-keys its edges. Follower counts are summed from the change stream by a small aggregator and written to the profile once a second.
Primitive: Database Sharding & Partition Keys · Drill: Sharding tenant hotspot
Step 2.2: Eight Million People Refresh Right After a Celebrity Posts
The problem: a 60M-follower account posts. The push notification goes out, and about 8M followers open the app over the next two minutes. Each of their feed reads pulls this account's latest posts from the same AUTHOR#<id> key.
What would you do?
Drill: Caching hot product page
Step 2.3: A Burst of Posts Overwhelms the Cache
The problem: big news breaks. Posting jumps to 10,000 a second for several minutes, three times our normal peak. The fanout workers read as fast as the stream gives them records and push about 2M writes a second into the cache. Cache latency goes from under a millisecond to tens of milliseconds, and every feed read, which also uses the cache, slows down with it. What would you do?
Primitive: Circuit Breaker, Bulkhead & Fault-Tolerance Patterns · Primitive: Message Queues vs Event Streams
Step 2.4: A User Returns After 30 Days to an Empty Timeline
The problem: a user who follows 600 accounts opens the app after 30 days. Their timeline expired long ago. Round 1's answer, query all 600 accounts at read time, takes well over a second. What would you do?
Step 2.5: A Deleted Post Still Shows for Some Followers
The problem: an author deletes a post. The delete event fans out a "remove this ID" to followers' timelines, but a cache node was failing over at that moment, and 15,000 timelines still hold the ID. Followers keep seeing a post the author deleted. What would you do?
Step 2.6: The Feed Must Be Ranked, Not Chronological
The problem: product wants each person's feed ordered by what they're most likely to care about. The whole read still has to fit inside 200 ms at P99. What would you do?
Primitive: Distributed Cache Patterns & Eviction
Round 2 Step Summary
| Step | Problem | Component | What it costs us |
|---|---|---|---|
| 2.1 | Celebrity post = 60M writes | Hybrid fanout: push under 25K followers (tunable, with hysteresis), pull above; write-sharded follower index | Reads merge two sources |
| 2.2 | Millions refresh one celebrity | Per-node single-flight, 1 s micro-cache, stale-while-revalidate; no DAX query cache for pulls | 1–2 s extra staleness; read headroom |
| 2.3 | Post bursts overwhelm the cache | Latency-driven backpressure; records wait in the stream (7-day retention); two bulkheaded consumer pools with enhanced fan-out | Fanout lag during bursts |
| 2.4 | Returning user, no timeline | Two-stage rebuild: top-20 fast page, then background rebuild; create-then-fill with a sentinel member; per-user lock in the session cache | Less complete first page |
| 2.5 | Deleted posts linger | Validate at hydration + read repair; best-effort removal fanout; writes through DAX, 60 s item TTL | A read-time filter |
| 2.6 | Ranking | Candidates → features → model → session-stored ranked list; 60 ms fallback to chronological | Model cost; session paging |
R2.5 Architecture v2
Synthesizing vector architecture diagram...
The timeline cluster holds only timelines. Everything short-lived (stored ranked lists, per-reader pull lists, rebuild locks) lives in a separate, smaller session cache, for a reason R2.8 explains. The write side now splits by audience size: authors under 1,000 followers and authors from 1,000 to 25,000 go through separate fanout pools, and authors at 25,000 or more skip fanout entirely. The read side runs four steps: gather candidates from the timeline and the celebrity pulls (1a and 1b in parallel), fetch features, score, and hydrate only what will be shown. A missing timeline triggers a background rebuild through SQS.
Trace 1: a regular user posts (1,200 followers)
textpost service ─► PutItem via DAX ─► change stream ─► pool B (1,200 ≥ 1K) pool B ─► registry says "push" ─► Query GSI1 FOLLOWED_BY#401#0 (2 pages) ─► 1,200 × "add if exists, trim to 800" in pipelined batches ─► checkpoint about 30 ms of cache work; followers who read in the next few seconds see it
Trace 2: a celebrity posts (60M followers)
textpost service ─► PutItem via DAX ─► change stream ─► both pools see "PULL" in the registry ─► skip, checkpoint no timeline changes at all; the post is visible to anyone whose feed node pulls AUTHOR#celeb (within ~1 s, the micro-cache TTL)
Trace 3: a ranked feed read
Synthesizing vector architecture diagram...
Page 2 skips everything before hydration: it reads offset 20–44 of the stored list and hydrates.
Trace 4: a user returns after 30 days
textfeed service: tl:802 missing ├─ stage 1 (on the request): top-20 interacted accounts × latest 2 posts + celebrity pulls │ → rank → hydrate → page 1 in the normal budget └─ stage 2 (SQS job): lock rebuild:802 (60 s) → create tl:802 = { sentinel "_" at score 0 } with 3-day expiry → Query latest posts of all 600 follows → merge → add → trim to 800
R2.6 Numbers and Cost
Traffic (the current track's figures, re-derived)
| Quantity | Math | Value |
|---|---|---|
| Posts/s, average | 100,000,000 ÷ 86,400 | ≈ 1,157 |
| Posts/s, peak | 1,157 × 3 | ≈ 3,471, call it 3,500 |
| Feed reads per day | 300M × 5 | 1.5B |
| Feed reads/s, average | 1.5 × 10⁹ ÷ 86,400 | ≈ 17,361 |
| Feed reads/s, peak | 17,361 × 3 | ≈ 52,083, call it 52K |
| Read-to-post ratio | 1.5B ÷ 100M | 15 : 1 |
Storage
| Quantity | Math | Value |
|---|---|---|
| Posts over 5 years | 100M × 365.25 × 5 | 182.6B |
| Post metadata | 182.6 × 10⁹ × 550 B | ≈ 100.4 TB |
| Media per post, average | 20% with a 200 KB image + 5% with a 2.5 MB video: 40 KB + 125 KB | 165 KB |
| Media per day | 100M × 165 KB | 16.5 TB |
The 100.4 TB is logical data. DynamoDB keeps it in three AZs as part of the service and bills the logical size, so we don't multiply by three. And a replication count is not a durability figure: the often-quoted "eleven nines" is S3's design target for objects, and it applies to the media in S3, not to the table.
Fanout writes. Assume 1% of posts come from accounts at or over the threshold (pulled), and pushed posts reach 200 followers on average (a planning assumption; we measure it):
The worst single pushed post is 24,999 followers: at 50,000 writes/s per worker, about 0.5 s.
Timeline memory: the carried-over estimate, and two checks.
The track sizes timelines like this:
That is 51.2 KB per timeline, a 1.35 factor for allocator fragmentation and engine overhead, and 60M timelines described as the "active 20%". We don't copy it; we check both inputs.
Check 1: why 60M timelines, when there are 300M daily users? Timelines exist only for users who read recently (step 1.5). For 60M to be enough, a timeline would have to expire within a few hours of its owner's last read, since a daily user comes back every few hours. Then most sessions would start with no timeline, and at even one rebuild per user per day that is rebuilds a second, each querying ~200 accounts: about 700,000 queries a second on average, more at peak. That is query-on-read for most sessions again, with step 2.4's less complete first page as the normal experience. So we choose the other side of the trade: every user who read in the last 3 days keeps a timeline (the expiry is 3 days, refreshed on read). We assume that is about 1.1 × DAU ≈ 330M timelines. Rebuilds are then only for people back after more than 3 days: assume 3% of DAU a day, 9M a day ≈ 104 a second, × 200 accounts ≈ 21,000 queries a second.
Check 2: is ~64 bytes an entry right? It is an estimate, and for the cache's default layout it is low. Past 128 members (the default zset-max-listpack-entries), a sorted set is stored as a hash table plus a skip list, and a bottom-up estimate per entry is:
| Part | Bytes (estimate) |
|---|---|
| Hash-table entry (24 B, rounded up by the allocator) | ~32 |
| Skip-list node (member pointer, score, back pointer, ~1.33 levels × 16 B), rounded up | ~53 |
| The member string (17–18 digits + header) | ~24 |
| Hash-table bucket slots | ~10 |
| Total | ~120 |
Exact figures vary by engine and version, so we measure MEMORY USAGE on a sample of real timelines before buying nodes. At ~120 B, even the track's 60M timelines would need about TB, not 4.15.
The lever: the compact encoding. Below the zset-max-listpack-entries limit, a sorted set is stored as a listpack: one packed block of bytes, where a member or score that looks like an integer is stored as a compact integer. The Redis documentation describes these small encodings as using up to 10 times less memory, and the limit as a CPU-for-memory trade you can tune. Our members (IDs) and scores (milliseconds) are both integers, so an entry is roughly 20–25 bytes. If we set the limit above our trim size (say 1,000, in a custom parameter group), every timeline stays compact. The price: an add or a trim scans and shifts the packed block, which is linear in its size (~20 KB here) instead of logarithmic. At our write rates that's affordable, but it's exactly the kind of change the Redis documentation says to benchmark first, so we load-test it.
| Option (330M timelines × 800 entries) | Per entry | Raw | With 25% overhead |
|---|---|---|---|
| Default encoding | ~120 B | 31.7 TB | ~39.6 TB |
| Compact encoding (our choice) | ~25 B (plus ~80 B per key) | 6.6 TB | ~8.3 TB |
Cache nodes. A cache.r7g.8xlarge has 209.55 GiB; after the default 25% reserved memory, about 157 GiB ≈ 169 GB, and we fill to 80%, about 135 GB per shard. , so 62 shards, each a primary plus one replica in another AZ: 124 nodes. (The default encoding would need about 294 shards.)
Egress
| Flow | Math | Value |
|---|---|---|
| Feed API responses, peak | 52,000 × 20 posts × ~550 B ≈ 52,000 × 11 KB | ≈ 572 MB/s ≈ 4.6 Gbps |
| Feed API responses, per month (through the ALB, not CloudFront) | 17,361/s × 11 KB × 2.63M s | ≈ 0.5 PB |
| Media, per page | assume 4 images at ~60 KB phone size + 1 video thumbnail at ~30 KB + ~30 KB of avatars; video bytes only when played, not counted | ≈ 300 KB |
| Media, per day | 1.5B pages × 300 KB | 450 TB (≈ 42 Gbps on average) |
| Media, per month | 450 TB × 30.4 | ≈ 13.7 PB |
Ranking compute. Assumptions, to be replaced by a load test: ~500 candidates per ranked request; one GPU instance (g5.xlarge, about $1.006/h) scores 20,000 candidates a second with our model; 60% of feed requests are first pages that need ranking (later pages read the stored list); instances run at 60% average utilization.
At 60% utilization that's about 317K instance-hours, ≈ $319K a month, or about $0.012 per 1,000 ranked feeds. At peak: GPUs busy.
DynamoDB capacity, and a quota. Hydration peaks at M item reads a second. With a 90% DAX hit rate (an assumption), 130K reach DynamoDB, 65K read units a second. On-demand tables have a default per-table throughput quota of 40,000 read units and 40,000 write units a second; we raise it well ahead of launch, and alarm on usage against it.
Monthly cost (us-east-1 on-demand list prices; rounded)
| Item | Math | Monthly |
|---|---|---|
Timeline cache, 124 × cache.r7g.8xlarge | 124 × $3.49/h × 730 h | ≈ $316K |
| Ranking GPUs | from above | ≈ $319K |
Feature store, 10 × cache.r7g.4xlarge (assumed size) | 10 × $1.745 × 730 | ≈ $13K |
| CloudFront data out, 13.7 PB of media | tiers: 10 TB at $0.085, 40 TB at $0.080, 100 TB at $0.060, 350 TB at $0.040, 524 TB at $0.030, 4 PB at $0.025 (≈ $140K for the first 5.02 PB), then 8.66 PB at $0.020 (≈ $173K) | ≈ $313K |
| CloudFront requests | 1.5B pages × 30.4 × 5 media requests = 228B × $0.01 per 10,000; an upper bound, since apps cache images on the device | ≤ $228K |
| API data out through the ALB, 0.5 PB | EC2 data-transfer-out tiers: 10 TB at $0.09, 40 TB at $0.085, 100 TB at $0.07, 352 TB at $0.05 | ≈ $29K |
| S3 media, a year in | ~6 PB in Intelligent-Tiering (frequent, infrequent after 30 days, archive instant access after 90) + monitoring fees | ≈ $65K |
| DynamoDB | writes ~9B WRU ($6K); hydration after DAX ($6K); celebrity pulls, upper bound ($14K); pull-list refreshes ($16K); rebuilds and fanout reads ($4K); storage a year in, ~45 TB ($11K) | ≈ $57K |
| DAX cluster | assumed ~10 memory-optimized nodes; confirm in the pricing calculator | ≈ $20K |
Session cache, 10 × cache.r7g.4xlarge | ranked lists: 31.2K ranked requests/s at peak × 1,800 s ≈ 56M sessions × ~4 KB (500 IDs) ≈ 225 GB, + pull lists ≈ 6 GB; × 1.25 ≈ 290 GB ÷ 68 GB per shard ≈ 5 shards, each with a replica: 10 × $1.745 × 730 | ≈ $13K |
| Compute | 78 feed nodes (c7g.2xlarge, $0.29/h), 24 fanout + 6 rebuild + 6 post nodes (c7g.xlarge, $0.145/h) | ≈ $20K |
| Kinesis | 10 shards, 2 enhanced fan-out consumers, 7-day retention | ≈ $1K |
| ALB, SQS, CloudWatch, misc. | ≈ $15K | |
| Total | ≈ $1.41M/month |
Why 78 feed nodes: we plan 1,000 feed requests a second per c7g.2xlarge (an assumption; the node mostly waits on network calls). Peak needs 52; losing an AZ must leave 52, so 26 per AZ × 3 = 78.
Read the table from the top: delivery (CloudFront), ranking and the timeline cache are almost everything. The first is what serving images to 300M people costs (and where large companies negotiate private pricing); the other two are the choices this round made. That's about half a cent per daily user per month.
R2.7 Trade-Offs
| Choice | We chose | What we give up |
|---|---|---|
| Threshold | 25K followers, tuned from fanout duration and pulled-accounts-per-read, with hysteresis | A number to watch and move; accounts near the line pay a ~12-minute double path when promoted, and a 3-day double path (or a backfill) when demoted |
| Hybrid vs pure push vs pure pull | Hybrid | Pure push: minute-long fanouts and cache floods for celebrities. Pure pull: every read queries hundreds of accounts (Round 1's baseline at 52K reads/s ≈ 10M queries/s). Hybrid: two code paths and a merge. |
| Ranked vs chronological | Ranked by default, chronological as the fallback and as an option | ~$319K a month of model serving; paging tied to a stored session; harder to explain "why am I seeing this" |
| Hydration from DAX vs straight from the table | DAX item cache, 60 s TTL, all writes through it | Another cluster to run; a write that bypasses DAX is stale for up to 60 s; never its query cache for "latest posts" (not invalidated by writes) |
| Who keeps a timeline | Everyone who read in the last 3 days (~330M), in compact encoding | ~$316K of memory, against rebuilding on most sessions |
| Compact vs default encoding | Compact (listpack), limit above 800 | Linear-time adds on a ~20 KB block; a benchmark before rollout; a tuning knob someone could reset |
R2.8 Failure Modes
| Failure | What you'd see | How the design responds |
|---|---|---|
| Celebrity read storm | Feed reads jump well above the planned peak within a minute of a notification | Pulls are coalesced per node (≤ ~1 read/s per account per node); the fleet autoscales, and has 50% headroom from AZ planning; notifications are spread over a couple of minutes. |
| Fanout backpressure | Iterator age rises in pool B; cache write latency flat | Working as designed: records wait in the stream (7 days), pool A keeps small authors fresh. Page only if pool A lags too, or lag passes 10 minutes. |
| Cache memory pressure | DatabaseMemoryUsagePercentage near 100%; Evictions above zero | The eviction policy decides what goes. On the timeline cluster we set maxmemory-policy to volatile-ttl: among keys that have an expiry, evict those closest to expiring. That only works because this cluster holds nothing but timelines, all with read-refreshed 3-day expiries. Short-lived keys (30-minute ranked lists, 60-second locks) would always be closest to expiring, so volatile-ttl would evict them first and break ranked paging for exactly the most active readers; they live in the separate session cache, where volatile-lru is fine. Our expiry is refreshed on read, so that means "least recently read", which is exactly who we'd drop. ElastiCache's default is volatile-lru, which evicts the least recently used key, and a fanout write counts as a use: a timeline its owner hasn't opened in two days but whose friends post a lot looks "recent" to it. allkeys-lru would also be willing to evict keys without an expiry. noeviction makes every fanout write fail with an out-of-memory error. Any eviction means we're under-sized, so it alarms. |
| Out-of-order inserts | None visible | Score = the post's time, so late or replayed events land in the right place; duplicate adds are no-ops. |
| Ghost posts | A deleted post in some timelines | Dropped at hydration, then removed from that timeline by read repair. |
| Losing an AZ | A third of the feed nodes gone; ~a third of cache primaries fail over | 52 feed nodes remain for a 52K peak; each shard's replica in another AZ is promoted (writes acknowledged but not yet copied are lost, which loses a few timeline entries, never a post); the ranking fleet runs in all three AZs; DynamoDB and DAX are multi-AZ. |
Drill: Circuit breaker cascading thread stall
R2.9 Production Gotchas
| Gotcha | Symptom | Cause | Fix |
|---|---|---|---|
| Full posts in timelines | Cache memory several times the plan; edits don't show | Post bodies copied into every follower's timeline | IDs only; hydrate at read |
| Untrimmed timelines | Memory creeps up every week; some timelines are huge | Adds without a trim, or a trim that runs separately and sometimes fails | Add and trim in one atomic step |
| No deliberate eviction policy | Fanout writes fail with out-of-memory errors, or active users lose timelines | noeviction, or a policy that doesn't match how expiries are refreshed | volatile-ttl on a cluster that holds only read-refreshed timelines (short-lived keys elsewhere); alarm on any eviction |
| A threshold chosen without data | Pool B lags every evening, or reads slow for people who follow many mid-size accounts | 25K treated as a law | Chart fanout duration and pulled accounts per read; tune with hysteresis |
| Ranking without a latency budget | Feed P99 follows the model's P99 | No timeout on scoring | 60 ms timeout, chronological fallback, and a budget table everyone signs up to |
| Celebrity pulls through the DAX query cache | A celebrity's new posts show up minutes late | Query-cache results aren't invalidated by writes | Pull with single-flight and a 1 s micro-cache instead |
R2.10 Pillar Check
| Pillar | What Round 2 adds |
|---|---|
| Reliability | Hybrid fanout removes the celebrity write storm; single-flight protects hot partition keys; backpressure and bulkheads keep bursts from spreading; AZ loss absorbed with no new capacity; the DynamoDB table quota raised ahead of need REL 1 · REL 5 · REL 10 |
| Performance Efficiency | A 200 ms budget split by stage; parallel candidate gathering; ranked lists stored per session so only first pages are ranked; compact timeline encoding PERF 1 · PERF 3 |
| Security | Tombstones honoured at hydration, so a deleted post can't be served from a stale timeline; signed, versioned cursors bound to the user and session |
| Cost Optimization | ≈ $1.41M/month, derived; the big three named (delivery, ranking, cache); compact encoding saves ~230 shards over the default; "who keeps a timeline" decided with the rebuild cost on the other side COST 5 · COST 6 |
| Operational Excellence | Freshness and fanout lag as SLOs per pool; eviction and quota alarms; a threshold tuned from dashboards, not guessed OPS 4 · OPS 8 |
| Sustainability | Light this round: Graviton for all non-GPU compute; memory sized to users who actually read; images delivered at phone size; videos fetched only when played SUS 2 · SUS 5 |
Alarms and first actions
| Signal | Alarm | First action |
|---|---|---|
| Feed P99 latency | > 200 ms for 5 min | Check which stage grew (per-stage timings in traces): pulls, features, model or hydration |
| Ranking fallback rate | > 2% of requests for 5 min | Check the ranking fleet's latency and capacity; scale out |
| Fanout lag, pool A | Iterator age > 5 s for 5 min | Scale pool A; check cache write latency |
| Fanout lag, pool B | Iterator age > 10 min | Scale pool B; check whether backpressure is holding it back (cache latency) |
| Cache evictions | > 0 | Add shards; check for timelines above 800 entries or a lost trim |
| DynamoDB throttles or quota usage | any throttling; usage > 70% of the table quota | Find the hot key (CloudWatch Contributor Insights); request a quota raise |
R2.11 Round 2 Rubric and Follow-Ups
What a senior (L6) answer adds over L5
- Makes fanout a per-author decision, and treats the threshold as a tuned choice with a method, not a magic number.
- Knows the limits are per partition key, and protects hot keys with coalescing and write sharding.
- Separates "how fast we read" from "how far we got": backpressure by reading slower, checkpoints for replay.
- Designs the returning-user path, including the race between rebuild and fanout (a sentinel member so the timeline exists before it is filled).
- Puts correctness at hydration (deletes, edits) instead of trying to keep every cache perfect.
- Gives ranking a budget and a fallback, and knows why ranked paging needs a stored session.
- Checks the carried-over sizing instead of trusting it, and finds the real cost drivers.
Follow-up questions
-
"A user follows 3,000 accounts, 400 of which are over the threshold. What happens to their reads?" Answer: 400 pulls per read breaks the 25 ms pull budget. We cap pulls per read (say the 50 pulled accounts they interact with most, rotating the rest across refreshes), and we can precompute a small "pulled posts" list for heavy followers in the background every minute. It's a per-reader decision, the mirror image of step 2.1's per-author one.
-
"Why not shard the timeline cache by region of the world, or by user popularity?" Answer: timelines are read by exactly one user, so we shard by that user's ID: every read is one shard, and every fanout write spreads across shards naturally. Sharding by anything else would make a read cross shards. Popularity matters for authors, and that is handled on the write side (the threshold and the pools), not by where timelines live.
-
"The ranking model team wants 2,000 candidates instead of 500." Answer: that is 4× the GPU cost (about $1.3M a month instead of $319K) and 4× the feature fetch. Before agreeing, we ask for the measured gain, and offer a cheaper shape: a light first-pass model over 2,000, and the heavy model only on the top few hundred. Round 3 builds exactly that.
Interview gotchas from this round
| Gotcha | Why it's wrong |
|---|---|
| "25,000 followers is where fanout stops working" | It's a starting point for a tunable threshold; the real limits are burst duration and read fan-in. |
| "Delay checkpoints for backpressure" | A checkpoint records progress; it doesn't slow reading. Slow the processing; the stream holds the rest. |
| "More DynamoDB capacity fixes the celebrity storm" | Limits are per partition key; only coalescing (or spreading the key) helps. |
| "Delete the ID from every timeline, then we're done" | Timelines are caches that are always being rebuilt; validate at hydration. |
| "60M cached timelines is the active 20%" | With 300M users reading every few hours, a timeline for only 60M means rebuilding on most sessions. |
Round 3 · Architect · "Global, Private by Default, and Abuse-Resistant"
~45 min · Principal (L7) · 4 regions · ~1B DAU · ~174K feed reads/s peak worldwide · 99.99% per region · privacy changes effective in seconds
R3.0 Where We Left Off
This is what the candidate says aloud in the first 60 seconds of Round 3. If you're starting here, it's everything you need from Rounds 1 and 2.
Round 2 in 60 seconds. "We run a ranked feed for 300 million daily users in one region: 100 million posts a day, about 52,000 feed reads a second at peak, 99.99%, P99 under 200 ms. Posts live once in DynamoDB, keyed by post ID. Fanout is hybrid: authors under 25,000 followers are pushed into their followers' timelines by two bulkheaded worker pools reading the table's change stream with backpressure; authors at or over the threshold are pulled at read time, with per-node single-flight and a one-second micro-cache so a celebrity's key sees about one read per node per second. Timelines are compact sorted sets of post IDs, 800 deep, for everyone who read in the last three days: about 330 million timelines, 8.3 TB, 62 cache shards. A read gathers ~500 candidates, fetches features, scores them on GPUs with a 60 ms fallback to chronological, stores the ranked list for the session, and hydrates 25 posts through DAX, dropping deleted ones and repairing the timeline. Returning users get a quick 20-account first page while the full timeline is rebuilt in the background. About $1.4 million a month, mostly delivery, ranking and the cache. Four costs are still open: it's one region; permissions are just 'public'; deletion is per post only; and ranking cost grows with every user."
Architecture v2, compact
textpost ─► DynamoDB (via DAX) ─► change stream ─► pool A (< 1K followers) / pool B (1K–25K) ─► timelines (62 shards) authors ≥ 25K: no fanout (registry snapshot) read ─► timeline top 400 ∥ celebrity pulls (single-flight, 1 s micro-cache) ─► features ─► GPU ranker (60 ms, else chronological) ─► rk:<user>:<session> ─► hydrate 25 via DAX, drop DELETED missing timeline ─► stage 1 (top-20 accounts) now; stage 2 rebuild via SQS (create with a sentinel, then fill; 60 s lock)
Steps so far
| Step | Problem | Component |
|---|---|---|
| 1.1–1.2 | Slow reads; memory | Fanout on write; IDs-only timelines; batch hydration |
| 1.3 | Paging repeats | Signed (score, post ID) cursor |
| 1.4 | Fanout in the request | Change stream → workers |
| 1.5–1.6 | Growth; order | Trim to 800; read-refreshed expiry; score = ID time |
| 2.1 | Celebrities | Hybrid push/pull, tunable threshold, write-sharded follower index |
| 2.2 | Read storms | Single-flight, micro-cache |
| 2.3 | Post bursts | Backpressure, bulkheaded pools |
| 2.4 | Returning users | Two-stage rebuild |
| 2.5 | Deletes | Validate at hydration, read repair |
| 2.6 | Ranking | Candidates → features → model, with a budget |
Open costs: one region; everything is public; account deletion isn't designed; ranking cost per user.
R3.1 The Scope Raise
Interviewer: "We're global now: about a billion daily users on every continent, and people follow each other across continents. Posts can be friends-only or shared with a custom list. When someone blocks a person, that person must disappear from their feed immediately, and the other way round. When a user deletes their account, it must vanish from every feed and be purged everywhere. Spam rings are creating accounts and posting to millions. And we must survive losing a whole region."
Again, we ask back before we design:
| We ask | Interviewer answers | What it changes in the design |
|---|---|---|
| Where are the users, and how many follows cross continents? | Four regions: the Americas, Europe, South Asia, and Southeast and East Asia. About 30% of follows cross regions. | Posts must reach readers in other regions, and each reader's feed should be served near them (step 3.1). |
| May a feed read call another region? | No. Each region must serve its own users even when the others are unreachable. | Everything a read needs (timelines, posts, follows, blocks) must be in the reader's region. |
| Who can see a friends-only post, and can the author change the audience later? | Friends means people the author follows back. Yes, authors can narrow the audience at any time. | A post's audience can change after it was fanned out. Fanout can't be the permission check (step 3.2). |
| How fast must a block or an audience change take effect? | By the next feed load, within seconds. Both directions: the blocker and the blocked. | Permission checks happen at read time against current data (step 3.2). |
| How fast must a deleted account disappear, and what must we prove? | From feeds, at once. Everything purged within the deadline our legal team sets, with a record that shows it. | A tombstone for the fast part, a workflow with an audit trail for the rest (step 3.3). |
| How much damage can one spam account do before we notice? | Today, a new account's post reaches all its followers in seconds. We want risky posts checked before they spread, and a way to pull a post from everywhere within a minute. | A gate before fanout, and a kill switch (step 3.4). |
| What if a region goes down? | Its users must still get a feed from another region, even a slightly stale or less-ranked one. We accept losing the last second or so of writes in that region until it returns. | Timelines are rebuilt, not replicated; the partner region needs headroom (step 3.5). |
| Can ranking cost grow in step with users? | No. The cost per user has to come down. | Tiered ranking (step 3.6). |
Scope change
| Round 2 | Round 3 | |
|---|---|---|
| Users | 300M DAU | ~1B DAU |
| Footprint | 1 region, 3 AZs | 4 regions, each serving its own users |
| Posts | 100M/day | ~333M/day; ≈ 3,860/s average, ≈ 11.6K/s peak |
| Feed reads | 1.5B/day; 52K/s peak | 5B/day; ≈ 57,900/s average, ≈ 174K/s peak worldwide |
| Survive | An AZ | A region |
| Visibility | Public | Public, friends-only, custom lists; blocks and mutes |
| Deletion | Posts | + whole accounts, purged everywhere, with proof |
| Abuse | Not designed | Risky posts checked before fanout; a one-minute kill switch |
| Availability | 99.99% | 99.99% per region; degrade to stale or less-ranked, never empty |
R3.2 What Breaks in the Round 2 Design
| Round 2 choice | What breaks at the new scope |
|---|---|
| One region | Readers on other continents pay 150–300 ms of network round trip before we do any work, and one region's outage is everyone's outage. |
| Fanout decides who sees a post | A post fanned out as public, then narrowed to friends, is already in thousands of timelines. A block made after the post doesn't remove anything. |
| Deletes rely on per-post tombstones and read repair | An account has thousands of posts, follow edges in both directions, media, search entries, features and cached ranked lists, in four regions. Nothing ties them together or proves they're gone. |
| Hydration through DAX | A global table's replicated writes are applied to the local replica directly, never through the local DAX. A post deleted or narrowed in another region stays in this region's DAX item cache for up to 60 s. |
| Every post fans out in seconds | A spam post from a day-old account reaches all its followers before any classifier looks at it. |
| One heavy model scores ~500 candidates per first page | At 1B users that's over a million dollars a month of GPUs (R3.6). |
R3.3 New Requirements and API Additions
An audience on every post, set at creation and changeable later:
httpPOST /v1/posts HTTP/1.1 Authorization: Bearer <token> Content-Type: application/json { "text": "Family dinner!", "media_ids": ["m_91xQ"], "audience": { "type": "LIST", "list_id": "l_fam" } }
audience.type is PUBLIC, FRIENDS or LIST. PATCH /v1/posts/{id} with a new audience changes who may see it from the next read on.
Blocks and mutes:
httpPOST /v1/users/555/block HTTP/1.1 Authorization: Bearer <token>
httpHTTP/1.1 200 OK Content-Type: application/json { "user_id": "555", "blocked": true, "effective": "next feed load" }
A block works both ways: neither person sees the other's posts. A mute (POST /v1/users/555/mute) only hides 555 from the muter's feed, and 555 is not told.
Account deletion:
httpDELETE /v1/me HTTP/1.1 Authorization: Bearer <token>
httpHTTP/1.1 202 Accepted Content-Type: application/json { "deletion_id": "del_3fK9", "status": "DELETING", "purge_deadline": "2031-03-31T00:00:00Z" }
GET /v1/deletions/del_3fK9 (internal, for support and audit) returns the workflow's state: DELETING (hidden from every feed, purge running) → PURGED, with counts of what was deleted in each store.
An integrity hook before fanout. The fanout workers call an internal decision service for every new post:
json{ "post_id": "683247088435237888", "author_id": "5550001", "decision": "HOLD", "reason": "new_account_link_burst", "review_by": "2031-03-01T09:44:00Z" }
decision is ALLOW, DELAY (fan out after a short wait), HOLD (visible only to the author until reviewed) or DENY.
R3.4 Design Evolution: Geography, Privacy, Deletion and Integrity
Step 3.1: Followers Live in Other Regions
The problem: a user in Mumbai follows a user in Dublin. The Dublin user posts. The Mumbai user must see it within seconds, and their feed read may not leave the Mumbai region. What would you do? Where do timelines live, and how does a post get there?
Primitive: Cloud Disaster Recovery & Multi-Region Active-Active · Drill: Replication multi-region consistency
Step 3.2: A Block Is Made, but the Posts Are Already in the Timeline
The problem: Priya blocks Sam. Sam's last 40 posts are already in Priya's timeline, in her stored ranked list for this session, and in her feed node's caches. A minute later, Sam narrows an old post from public to friends-only; it was fanned out to 3,000 people who are not his friends. What would you do?
Drill: JWT revocation token blacklist
Step 3.3: A Deleted Account Must Vanish Everywhere
The problem: a user with 12,000 posts, 3,000 followers and 900 follows deletes their account. Their data is in four regions: posts, edges in both directions, media in S3 and CloudFront, search entries, ranking features, cached ranked lists and timelines. We must hide it at once, purge it by a deadline, and be able to prove we did. What would you do?
Primitive: Change Data Capture & the Outbox Pattern
Step 3.4: A Spam Wave Is Being Fanned Out to Millions
The problem: a ring of 5,000 accounts, each a few days old and each followed by thousands of bought or compromised accounts, starts posting a scam link. Every post is fanned out within seconds. By the time moderators see reports, millions of timelines hold it. What would you do?
Primitive: Bot Defense, Sybil Resistance & Registration Abuse · Drill: Bot defense credential-stuffing surge · Loop: Design a Distributed Rate Limiter
Step 3.5: A Region Is Gone
The problem: us-east-1 goes down during its evening peak. 300 million users call it home. Their timelines, their region's cache and its fanout workers are unreachable. What would you do?
Synthesizing vector architecture diagram...
The partner serves the lost region's users from data it already holds. Only the timelines are new, and only for users who show up.
Primitive: Cloud Disaster Recovery & Multi-Region Active-Active
Step 3.6: Ranking Now Costs More Than Serving
The problem: Round 2's ranker scores ~500 candidates per first page on GPUs. At a billion users that is over a million dollars a month (R3.6), and the model team wants more candidates, not fewer. What would you do?
Round 3 Step Summary
| Step | Problem | Component | What it costs us |
|---|---|---|---|
| 3.1 | Followers in other regions | Reader-home timelines; global tables for posts, edges, profiles, blocks; each region fans out locally from its replica's stream; one writing region per item | ~1 s cross-region delay; 4 copies of data |
| 3.2 | Blocks and audience changes | Read-time filter at hydration (status, blocks both ways, mutes, audience); cached, invalidated block sets; DAX replaced by a post and profile cache that the stream consumer evicts; pre-filter before ranking | A filter on every read |
| 3.3 | Account deletion | Account tombstone (instant hiding) + Step Functions purge in the home region + audit record; no TTL as a deadline | A pipeline to run and prove |
| 3.4 | Spam waves | Integrity gate before fanout (allow, delay, hold, throttle); a deny-list kill switch through the read filter | Slower spread for new accounts |
| 3.5 | Region loss | Route 53 failover to a partner; lazy, small, 6-hour timelines; partner fans out the lost region's keys; light ranking only | Rebuild storm; headroom |
| 3.6 | Ranking cost | CPU first pass over all candidates, GPU heavy model on the top 50; precomputed features; ranked first pages at rebuild | A second model; small accuracy loss |
R3.5 Global Architecture
Synthesizing vector architecture diagram...
Every region is a Round 2 system that serves only its own users. The global layer is data, not traffic: tables that replicate, one CDN in front of every region's media, and DNS that knows each region's partner. Nothing on a feed read crosses a region.
Trace 1: a cross-region follow and post (Mumbai reader, Dublin author)
textMumbai (ap-south-1): POST /v1/users/<dublin_author>/follow └─ PutItem edge (written in the follower's home region): GSI1PK = FOLLOWED_BY#<author>#ap-south-1#0 replicates to the other three regions (~1 s) Dublin (eu-west-1): author posts ─► PutItem POST#<id> (eu-west-1 writes it) └─ replicates to ap-south-1 (~1 s) Mumbai: replica's DynamoDB Streams ─► forwarder ─► Kinesis ─► integrity gate: ALLOW └─ pool A queries FOLLOWED_BY#<author>#ap-south-1#* ─► add to the Mumbai reader's timeline Mumbai reader: GET /v1/feed ─► served entirely from ap-south-1
Trace 2: a block
textPriya (eu-west-1) blocks Sam (us-east-1): 1. eu-west-1 writes BLOCKS edge (Priya → Sam) and deletes Priya's cached block set 2. Priya's next read (same region): Sam's posts dropped at hydration. Effective immediately. 3. edge replicates to us-east-1 (~1 s); a stream consumer there deletes Sam's cached block set 4. Sam's next read: Priya's posts dropped. Effective within seconds.
Trace 3: a region failover (see the diagram in step 3.5)
textt = 0 us-east-1 health checks fail t ≈ 2 min Route 53 answers us-east.api with eu-west-1's endpoint (health-check interval × failure threshold, + record TTL) t > 2 min us-east-1 users hit eu-west-1: timeline miss → stage 1 page → 200-entry rebuild (6 h expiry) eu-west-1 fanout also processes FOLLOWED_BY#…#us-east-1 keys failed-over users: light ranker or chronological return replication resumes; late writes arrive; Route 53 fails back; the temporary timelines expire
R3.6 Numbers and Cost
Traffic per region (a planning split; each region is planned for its own peak at 3× its average)
| Region | Share | DAU | Posts/day | Reads/s average | Reads/s peak |
|---|---|---|---|---|---|
| us-east-1 | 30% | 300M | 100M | 17,361 | ≈ 52,100 |
| eu-west-1 | 25% | 250M | 83.3M | 14,468 | ≈ 43,400 |
| ap-south-1 | 25% | 250M | 83.3M | 14,468 | ≈ 43,400 |
| ap-southeast-1 | 20% | 200M | 66.7M | 11,574 | ≈ 34,700 |
| Total | 1B | 333M | 57,870 | ≈ 174K (if the peaks lined up) |
Posts: M a day /s average, /s at peak. us-east-1 alone is exactly Round 2's system.
Cross-region replication
| Flow | Math | Per month |
|---|---|---|
| Post items to 3 other regions | 333M/day × 550 B × 3 × 30.4 | ≈ 16.7 TB |
| Replicated writes, posts (every replica bills replicated write units) | 333M × 4 × 30.4 ≈ 40.5B × $0.625/M | ≈ $25K |
| GSI writes on posts, in every region | same count, standard write units | ≈ $25K |
| Follow edges and blocks (assume 170M changes a day, base + index) | similar arithmetic | ≈ $25K |
| Inter-region transfer for items | 16.7 TB × $0.02/GB | ≈ $0.3K |
| Media copies to the partner region (for DR) | 333M × 165 KB ≈ 55 TB/day × 30.4 ≈ 1.67 PB × $0.02/GB | ≈ $33K |
Prices above are us-east-1 list prices; other regions cost somewhat more.
Timeline cache per region (from R2.6: 330M timelines ≈ 8.3 TB, so ≈ 2.51 TB per 100M timelines; 135 GB usable per cache.r7g.8xlarge shard)
| Region | Timelines (1.1 × DAU) | Size | Shards | + failover headroom | Nodes (primary + replica) |
|---|---|---|---|---|---|
| us-east-1 | 330M | 8.3 TB | 62 | + 6 | 136 |
| eu-west-1 | 275M | 6.9 TB | 52 | + 6 | 116 |
| ap-south-1 | 275M | 6.9 TB | 52 | + 6 | 116 |
| ap-southeast-1 | 220M | 5.5 TB | 41 | + 6 | 94 |
| Total | 1.1B | 27.6 TB | 207 | + 24 | 462 |
Failover headroom: 120M temporary timelines (step 3.5) × (200 entries × 25 B + 80 B) ≈ 610 GB × 1.25 ≈ 760 GB ≈ 6 shards, reserved in every region so any partner can take the other's users.
Ranking cost per 1,000 ranked feeds (planning assumptions to confirm by load test: a c7g.4xlarge, about $0.58/h, scores 500,000 candidates a second with the first-pass model; a g5.xlarge, about $1.006/h, scores 20,000 a second with the heavy model; 60% average utilization)
| Round 2 (one heavy model) | Round 3 (tiered) | |
|---|---|---|
| CPU first pass | – | 1,000 × 500 ÷ 500,000 = 1 CPU-instance-second ≈ $0.00016 |
| GPU heavy model | 1,000 × 500 ÷ 20,000 = 25 GPU-s ≈ $0.0070 | 1,000 × 50 ÷ 20,000 = 2.5 GPU-s ≈ $0.0007 |
| At 60% utilization | ≈ $0.0116 | ≈ $0.0014 |
Ranked requests: B a month. Round 2's design at this scale: 91.2\text{M} \times \0.0116 \approx $1.0691.2\text{M} \times $0.0014 \approx $131$K a month.
Monthly total (list prices, us-east-1 rates where regional prices differ; rounded; storage a year in)
| Item | Basis | Monthly |
|---|---|---|
| Media and API delivery | Media through CloudFront: 5B pages × 300 KB × 30.4 ≈ 45.6 PB. North America and Europe (55%, ~25.1 PB): ≈ $541K. Asia (45%, ~20.5 PB) at over-5-PB rates of $0.060–0.072/GB: ≥ $1.23M. Requests: 760B media requests at $0.010–0.012 or more per 10,000 depending on the edge region: ≈ $0.8M (less where apps cache images). API responses, ~1.7 PB through the four regions' ALBs at EC2 transfer-out rates (higher in Asia): ≈ $130K | ≈ $2.7M+ |
| Timeline cache | 462 nodes | ≈ $1.18M |
| S3 media, with partner-region copies | Round 2's $65K × 3.33 × 2 | ≈ $430K |
| DynamoDB (regional reads and writes, 4× storage, item replication) | Round 2's usage × 3.33, storage × 4, + item replication ($75K, above) | ≈ $375K |
| Media copies to partner regions (transfer) | above | ≈ $33K |
| Ranking (tiered) | above | ≈ $131K |
| Compute, incl. failover warm pools | Round 2's $20K × 3.33, + 30% | ≈ $87K |
| Post and profile caches (replacing DAX), feature stores | Round 2's DAX + feature store, $33K × 3.33 | ≈ $110K |
| Session caches (ranked lists, block sets, locks) | Round 2's $13K × 3.33 | ≈ $43K |
| Kinesis, Step Functions, SQS, ALBs, CloudWatch, misc. | ≈ $60K | |
| Total | ≈ $5.15M/month |
About half a cent per daily user per month. Delivering images is about half the bill, and at this volume it is negotiated, not paid at list price. The two lines this loop designed are the cache (a choice about who keeps a timeline and how it's encoded) and ranking (tiered, eight times cheaper than scaling Round 2's).
R3.7 Trade-Offs
| Choice | We chose | What we give up |
|---|---|---|
| Reader-home vs writer-home timelines | Reader-home: timelines where the reader is; posts replicate; each region fans out locally | Four copies of posts and edges, ~1 s cross-region delay. Writer-home would put a cross-region write on every remote follower and couple regions' health. |
| Filter at read vs rewrite on change | Filter at read, always; fanout only as a speed-up | A filter on every read. Rewrites would be slow, racy and never complete. |
| Integrity delay vs reach | Gate before fanout for risky accounts; throttle new accounts | Honest new users spread slower; false positives delay real posts |
| Ranking tiers | CPU first pass over all candidates, GPU heavy model on the top 50 | A second model; a measured recall loss |
| Replicated timelines vs rebuild on failover | Rebuild lazily, small, degraded | A rebuild storm and headroom, instead of doubling the cache |
| Global tables' consistency mode | Default (eventual, multi-region writes) with one writing region per item | Last-writer-wins settles overlapping writes after a failover (step 3.5), or if the single-writer rule is ever broken; strong mode is limited to exactly three regions |
Closing the loop. The opening question was: do we do the work when someone posts, or when someone reads, and who decides for each post? The answer is now:
- Speed work happens at write time for almost everyone: precomputed timelines, in the reader's region.
- It moves to read time where precomputing costs too much: celebrities (by a tuned threshold), returning users (a quick first page), and failed-over users (small temporary timelines).
- Correctness work always happens at read time: deletes, blocks, audiences, account tombstones and the kill switch are checked against current data on every page. Precomputed must never mean allowed.
- Who decides: the author's follower count decides push or pull, the reader's recency decides whether they keep a timeline, and the integrity gate decides whether a post fans out at all.
R3.8 Failure Modes
| Failure | What you'd see | How the design responds |
|---|---|---|
| A region outage | Health checks fail; the region's users are routed to its partner | Step 3.5: data is already there, timelines rebuilt lazily and small, light ranking. Late writes from the lost region arrive when it returns. |
| A replication lag spike | ReplicationLatency climbs from ~1 s to minutes for one region pair | Cross-region posts arrive late there; local posts are unaffected. Blocks made in the far region also take effect late here (a block always works at once in the region where it was made). We can't do better without a cross-region read, which we don't allow, so we alarm, check the source region's health, and fail it over if it is the cause. |
| The block-set cache goes stale (an invalidation lost) | A blocked author's post shown once | The cached set expires within 60 seconds regardless; a block also triggers a second, delayed invalidation 5 seconds later. We alarm on invalidation failures. |
| The deletion pipeline backs up | Executions older than a few days | Hiding is unaffected (the tombstone does it). Scale the workflow's workers; page if any execution nears the legal deadline. |
| A spam wave during a region outage | Integrity decisions in the partner region spike | The gate runs in every region on every region's stream, so it follows the traffic. The deny list is a global table written from any region; entries are additive, so last-writer-wins can't lose one. |
| A bad ranking model deploy | Engagement drops, or ranking latency rises | Models deploy one region at a time behind a canary; the 60 ms timeout falls back to chronological; roll back by switching the model version. |
R3.9 Runbook and Incident Response
Golden signals, per region OPS 8 · REL 6
| Signal | Alarm | Severity | First action |
|---|---|---|---|
| Feed P99 latency | > 200 ms for 5 min | P2 | Per-stage timings: pulls, features, ranking, hydration, privacy filter |
| Feed error rate | > 0.1% for 5 min | P1 | Which dependency is failing? Is the region healthy enough, or should it fail over? |
| Fanout lag, pool A / pool B | > 5 s / > 10 min | P2 / P3 | Scale the pool; check cache write latency and backpressure |
| Timeline cache hit ratio | < 95% of reads find a timeline | P3 | A rebuild storm? Evictions? A failover in progress? |
| Hydration misses (posts dropped as missing or deleted) | > 2% of hydrated posts | P3 | Replication lag, a purge in progress, or a spam kill wave |
| Privacy-filter errors (block, audience or list lookups failing) | any | P1 | The filter fails closed (the post is dropped); find the failing lookup before feeds empty out |
Cross-region ReplicationLatency | > 30 s for 5 min | P2 | Check the source region; expect late cross-region posts and blocks |
Integrity gate latency, or HOLD rate | P99 > 50 ms, or a 3× jump in holds | P2 | A spam wave, or a bad classifier deploy |
| Deletion executions past 3 days | any | P3 | Find the stuck step; retry or fix |
Kill-switch procedure (a post or an account must disappear from every feed now) SEC 10 · OPS 10
- Add the post IDs or account ID to the deny list (CLI 5 below). Every feed node and fanout worker in every region reloads it within 5 seconds; hydration drops the post, fanout skips it.
- Confirm. The deny-list size metric rises in all four regions; a test account that follows the author no longer sees the post.
- Make it permanent. Set the post's
statustoDELETED(or start account deletion, step 3.3). Invalidate the media paths in CloudFront. - For a ring of accounts, list their posts (CLI 6) and add them in bulk; set the gate to
HOLDfor accounts that share the ring's signals. - Record it: who, what, why, when, and a link to the evidence, in the incident ticket. Deny-list entries carry the operator and the reason.
Go deeper: CLI playbook
Plain commands an on-call engineer runs, one at a time. Replace the IDs and ARNs with real ones.
text# 1. Fanout lag for pool B (enhanced fan-out consumers report MillisBehindLatest) aws cloudwatch get-metric-statistics --region us-east-1 --namespace AWS/Kinesis --metric-name SubscribeToShardEvent.MillisBehindLatest --dimensions Name=StreamName,Value=post-events Name=ConsumerName,Value=fanout-pool-b --statistics Maximum --period 60 --start-time 2031-03-01T09:00:00Z --end-time 2031-03-01T10:00:00Z # 2. Cross-region replication lag into eu-west-1 aws cloudwatch get-metric-statistics --region us-east-1 --namespace AWS/DynamoDB --metric-name ReplicationLatency --dimensions Name=TableName,Value=SocialCoreTable Name=ReceivingRegion,Value=eu-west-1 --statistics Average --period 60 --start-time 2031-03-01T09:00:00Z --end-time 2031-03-01T10:00:00Z # 3. Scale fanout pool B aws ecs update-service --region us-east-1 --cluster feed --service fanout-pool-b --desired-count 40 # 4. Timeline cache status (shards, failover state) aws elasticache describe-replication-groups --region us-east-1 --replication-group-id timelines-use1 # 5. Kill switch: add a post to the deny list (a global table; any region) aws dynamodb put-item --region us-east-1 --table-name IntegrityDenyList --item '{"PK": {"S": "POST#683247088435237888"}, "reason": {"S": "scam-link-wave"}, "added_by": {"S": "oncall-alice"}, "added_at": {"S": "2031-03-01T09:42:00Z"}}' # 6. List an account's posts for a bulk kill aws dynamodb query --region us-east-1 --table-name SocialCoreTable --index-name GSI1 --key-condition-expression "GSI1PK = :a" --expression-attribute-values '{":a": {"S": "AUTHOR#5550001"}}' --projection-expression "PK" # 7. Account deletions still running aws stepfunctions list-executions --region us-east-1 --state-machine-arn arn:aws:states:us-east-1:111122223333:stateMachine:account-deletion --status-filter RUNNING # 8. Remove a killed account's media from the CDN aws cloudfront create-invalidation --distribution-id E2EXAMPLE12345 --paths "/u/5550001/*" # 9. Is a region's API health check failing? aws route53 get-health-check-status --health-check-id 11111111-2222-3333-4444-555555555555
R3.10 Pillar Check
| Pillar | What Round 3 adds |
|---|---|
| Reliability | Four independent regions with paired failover; timelines rebuilt instead of replicated; one writing region per item, so last-writer-wins decides only during a failover; partner quotas and warm pools prepared for the rebuild storm REL 1 · REL 10 · REL 13 |
| Performance Efficiency | No cross-region call on a read; reader-home timelines; tiered ranking keeps the budget; pre-filtering blocked authors before scoring PERF 1 · PERF 4 |
| Security | Permissions checked on every read against current data (blocks both ways, audiences, tombstones); an integrity gate before distribution; a kill switch with an audit trail; account purges with proof SEC 7 · SEC 10 |
| Cost Optimization | ≈ $5.15M/month, derived; delivery named as the biggest line; tiered ranking about 8× cheaper per feed; failover by rebuild instead of a second cache COST 5 · COST 8 |
| Operational Excellence | Per-region golden signals with first actions; a kill-switch procedure; deletion deadlines watched by the workflow; region-by-region model rollouts OPS 6 · OPS 8 · OPS 10 |
| Sustainability | Memory only for users who read recently, in a compact encoding; failover timelines small and short-lived; the cheap ranker does most of the scoring; images at phone size, video only on play SUS 2 · SUS 3 · SUS 4 |
R3.11 Round 3 Rubric and Follow-Ups
What an architect (L7) answer adds over L6
- Separates speed from permission: fanout is an optimization, every read checks current data.
- Places data by who reads it (reader-home timelines) and who writes it (one writing region per item, handed to the partner during a failover), and knows what global tables do with conflicts.
- Designs deletion as a provable workflow with a fast hiding step, and refuses TTL as a deadline.
- Puts abuse controls before distribution, and builds the kill switch on the read-time filter that already exists.
- Treats timelines as a cache even in disaster recovery, and sizes the rebuild storm instead of doubling the cache.
- Finds where the money goes (delivery, cache, ranking) and changes the design where it's ours to change.
Follow-up questions
-
"A user moves from Europe to India. What happens to their home region?" Answer: their profile's
home_regionchanges, written by the old home region (the single writer for that item) and then owned by the new one. Their follow edges keyed by region need re-keying: a background job rewrites theirFOLLOWED_BY#…#<region>entries under the new region. Their timeline isn't moved; the new region rebuilds it (step 2.4). Until the re-keying finishes, both regions' fanouts may reach them, and the timeline's duplicate adds are harmless. -
"Legal says some countries require their users' posts to stay in-country. Does this design allow it?" Answer: not as is: global tables replicate every item to every replica region. We'd split posts into a second table that is only replicated among allowed regions, and readers elsewhere would either not see those posts or fetch them across a region. That breaks "no cross-region call on a read" for those posts, which is a product and legal decision to make explicitly, not an engineering default.
-
"Why not rank with the heavy model and just buy more GPUs? Engagement matters more than cost." Answer: then measure it. Run both on a slice of traffic and compare engagement against the ~$0.9M a month difference. If the heavy-only model wins by enough, buy the GPUs. The tiered design isn't a rule; it's the default until the numbers say otherwise.
Loop Closer: Interview Strategy for All Three Rounds
How to Run Each 60-Minute Round
| Time | Round 1 | Round 2 | Round 3 |
|---|---|---|---|
| 0–5 min | Scoping questions: order? follows? freshness? repeats? | Restate the Round 1 design in 60 seconds | Restate the Round 2 design in 60 seconds |
| 5–15 min | Requirements + API (pre-signed uploads, opaque cursor) | Scope raise → what breaks | Scope raise → what breaks |
| 15–40 min | Steps 1.0–1.6: query on read → fanout on write → IDs only → cursor → change stream → trim → score = time | Steps 2.1–2.6: hybrid fanout and threshold, coalescing, backpressure, returning users, validate at read, ranking | Steps 3.1–3.6: reader-home regions, read-time permissions, account deletion, integrity gate, region loss, tiered ranking |
| 40–50 min | Numbers: traffic, fanout writes, memory, cost | Numbers: memory check (who, how many bytes), ranking, cost | Numbers: per region, replication, cache, ranking per 1,000 feeds, cost |
| 50–60 min | Failures + pillar check | Failures + pillar check | Failures, runbook, pillar check |
For how to spend a single 45-minute round, see the 45-minute interview blueprint.
The Two Sentences That Matter Most
- Opening a round: "Before I design: is the feed chronological or ranked, how many accounts does a user follow, and how fresh does it need to be?"
- When the scope is raised: "Here's what breaks in the current design, and I'll fix it in this order: anything that can show someone a post they shouldn't see, then anything that can take reads down, then cost."
Well-Architected Review Sheet
Interviewers rarely ask "which pillar is this?". They ask the pillar's question in plain words. Rehearse one sentence per row.
| Pillar | Question you'll hear | One-sentence answer | Round | Backed by |
|---|---|---|---|---|
| Reliability | "What if the timeline cache loses a shard?" (REL 11) | Timelines are derived data: readers fall back to building them from posts and follows, slower but never wrong. | 1 | R1.9 |
| "What happens when a celebrity posts?" (REL 5) | Nothing is pushed; readers pull, and each feed node asks for that account about once a second. | 2 | Steps 2.1–2.2 | |
| "What if posting spikes during big news?" (REL 5) | Workers slow down when the cache slows; records wait in the stream; small authors have their own pool. | 2 | Step 2.3 | |
| "What if a whole region goes down?" (REL 13) | Its users move to a partner region that already has the data, and get small rebuilt timelines with light ranking. | 3 | Step 3.5 | |
| Performance | "Why is a feed read fast?" (PERF 1) | One range read of precomputed IDs and one batched hydration, inside a 200 ms budget split by stage. | 1–2 | Steps 1.1–1.2, 2.6 |
| "How do you page a feed that keeps changing?" (PERF 3) | A signed cursor on (score, post ID) for chronological, and a stored per-session list for ranked. | 1–2 | Steps 1.3, 2.6 | |
| Cost | "What does it cost, and where does the money go?" (COST 5) | About $12.3K, $1.41M and $5.15M a month; delivery, the timeline cache and ranking are almost all of it. | 1–3 | R1.7, R2.6, R3.6 |
| "Why not cache a timeline for every user?" (COST 6) | We cache everyone who read in three days, compactly; beyond that, a rebuild on return is cheaper than the memory. | 2 | R2.6, Step 2.4 | |
| Operations | "How do you know the feed is healthy?" (OPS 8) | Feed P99, fanout lag per pool, cache hit ratio, hydration misses and privacy-filter errors, each with a first action. | 2–3 | R2.10, R3.9 |
| "How do you pull a post from every feed right now?" (OPS 10) | Add it to the deny list; every node reloads it within seconds, and hydration drops it. | 3 | Step 3.4, R3.9 | |
| Security | "How does a block take effect on posts already in someone's feed?" | Every read checks blocks, audiences and tombstones against current data; fanout is never the permission check. | 3 | Step 3.2 |
| "How do you prove a deleted account is gone?" (SEC 7) | A tombstone hides it at once; a workflow purges each store and writes an audit record, with the deadline watched. | 3 | Step 3.3 | |
| Sustainability | "Where is this system wasteful?" (SUS 4) | Memory for people who never read and heavy models on posts nobody sees; we fixed both with expiry, compact timelines and tiered ranking. | 2–3 | R2.6, Step 3.6 |
Rubric Across Levels
| Dimension | L5 (Round 1) | L6 (Round 2) | L7 (Round 3) |
|---|---|---|---|
| Fanout | Moves from query-on-read to fanout on write, off the request path, with a reason. | Hybrid push/pull with a tuned threshold; hot keys handled by coalescing and write sharding. | Local fanout per region from replicated posts; an integrity gate decides whether a post fans out at all. |
| Timelines | IDs only, sorted by the ID's time, trimmed and expired; knows they are rebuildable. | Decides who keeps one and how it is encoded, from a rebuild-vs-memory calculation; handles returning users. | Reader-home placement; rebuilds instead of replicas in disaster recovery. |
| Reads and paging | Batch hydration; a signed (score, post ID) cursor. | Ranking pipeline with a budget and fallback; session-stored ranked lists; validate at hydration. | Read-time permission filter (blocks, audiences, tombstones, deny list) on every page. |
| Correctness under change | Duplicates and reordering made harmless. | Deletes and edits correct at read time; rebuild races closed. | Blocks, audience changes, account deletion and kill switches, all correct across regions. |
| Numbers | Derives traffic, fanout writes, memory and a monthly cost. | Re-checks carried-over sizing (count and bytes per entry) and finds the cost drivers. | Prices regions, replication, failover headroom and ranking per 1,000 feeds. |
| Well-Architected trade-offs | Memory for read speed, stated as a trade. | Freshness for stability in bursts; staleness for protection of hot keys. | Integrity delay for reach; rebuild storms for half the cache bill; ranking accuracy for cost. |
| Evolving under new scope | Builds from the baseline, one problem at a time. | Opens with what breaks; fixes load dangers first, then product features. | Changes where data lives and who is allowed to see it, and says what the business must decide. |