Follower timeline — a full worked answer
The whole thing, start to finish, in the order you would actually say it. Read it to learn the shape of a strong answer — then go and produce one yourself, because reading is not the same skill as producing.
The question
Design the home timeline for a social product. People follow each other; when you open the app you see recent posts from everyone you follow, newest first. Some accounts have tens of millions of followers.
Write-heavy / fan-out. One action by one person creates work for thousands of other people.
The question is always the same: do you do that work when they write, or when everyone else reads? And what do you do about the one user with 200 million followers?
Before anything: where is the slack?
How long the system has before a person notices. Ask this before you draw a single box — it decides more of the design than scale does.
A reader cannot tell when a post was supposed to appear in their feed. So you have seconds of slack on delivery, and almost none on opening the app.
This is the key asymmetry. Opening the app has a hard budget: the timeline must render in a couple of hundred milliseconds or the product feels broken. But readers do not time when someone else's post shows up. If it appears two seconds after it was posted, or ten, the reader cannot tell, because they have no reference clock.
That slack is what makes fan-out on write possible. You can do a large amount of work asynchronously between "they posted" and "it appears in feeds", because readers are not watching that interval. The exception is the author, who knows when they posted and will often refresh at once. Their view of their own post has zero slack and needs a different path.
Counts (likes, replies, views) have minutes of slack. A reader cannot tell 4,102 from 4,118. That is what lets you batch and approximate them instead of writing to a contended row on every interaction.
So: Seconds of delivery slack means you can precompute timelines in the background rather than assembling them on read. Zero slack on the author's own view means you special-case it. Minutes of slack on counts means they never go near the hot path.
Requirements
about 5 minWhy this stage exists: Because the question is deliberately vague, and every box you draw before this is a guess.
Assumptions: a few hundred million users, global, mostly mobile. Posts are small text plus optional media references. Reads dominate: people open the app far more than they post. Most people follow a few hundred accounts, and a handful of accounts have tens of millions of followers. Auth is solved elsewhere.
First question, and it is the one that changes the most boxes: is the timeline strictly reverse-chronological, or ranked by relevance? Ranked means I cannot simply precompute and append: every read may need scoring, and I need a feature store and a model serving path. I will assume reverse-chronological with a light recency-and-affinity ordering applied at read time, which keeps precomputation viable.
Second question: must a post be visible in followers' feeds immediately, or is a few seconds acceptable? This decides synchronous versus asynchronous fan-out. It sounds like a product detail, but it is the central architectural constraint. I will assume a few seconds is fine.
Scope I propose: post, follow and unfollow, and render the home timeline. Out of scope: ranking models, direct messages, notifications, and media storage. I will treat media as a reference to a separate service rather than pretend it does not exist.
What an interviewer is ticking off in this stage
- Stated the follower distribution as skewed: a few accounts have tens of millions
- Asked whether the timeline is chronological or ranked, and said why it changes the design
- Asked whether delivery must be immediate or can take seconds
- Stated that reads dominate writes
- Proposed scope and named what is out of it
Actors and lifecycle
about 6 minWhy this stage exists: Because the happy path is the easy half. The failure branches are where the design actually lives.
Actors: the author, the followers who read, and the system. The system makes the decisions that matter: whether to fan this post out or leave it to be pulled, when a post is "delivered", what to do with followers who have not opened the app in months, and when to stop trying.
A post's life: composed → accepted and durable → queued for fan-out → written into follower inboxes → read → possibly deleted.
Failure branches. At accepted: the post is stored but the fan-out event is lost, so it never reaches anyone. That is why the event must be written in the same transaction as the post, via an outbox, rather than published separately. At queued: the author has 30 million followers, so this single job is 30 million writes and will flood the queue and delay everyone else's posts. The branch: above a threshold, do not fan out at all, and let readers pull instead. At writing to inboxes: the job crashes after 8 million of 30 million writes, so it retries and rewrites the first 8 million. That is safe only because inbox writes are idempotent on post id, which makes idempotency a requirement here. At read: a follower's inbox is missing entries because their fan-out is still in flight, so their timeline is briefly short rather than wrong, which is acceptable and worth saying. At deleted: the post is now in millions of inboxes and cannot be deleted from all of them quickly. So deletion is a tombstone plus filtering at read time, and the inbox entries are cleaned up lazily.
A follow also has a life worth a sentence: followed → backfill decision. Following someone does not retroactively insert their old posts into your inbox, because that would be another enormous write job. Their older posts appear via the pull path or are not shown. Saying this out loud is a good signal, because it is easy to overlook.
What an interviewer is ticking off in this stage
- Named the system as an actor and listed decisions it makes on its own
- Wrote the post lifecycle as a chain
- Handled the lost fan-out event: outbox, in the same transaction
- Handled the huge-account fan-out as a branch, not as a footnote
- Required inbox writes to be idempotent because fan-out jobs retry
- Handled deletion of a post already fanned out: tombstone and lazy cleanup
- Mentioned what happens on a new follow
The lifecycle, drawn
Numbers
about 6 minWhy this stage exists: Because one part of this is hard and the rest is easy, and they look identical until you do the arithmetic.
Assume 200 million daily active users. Say each posts 0.5 times a day on average: 100 million posts a day, about 1,000 posts per second, peak maybe 3,000. That is a modest write rate on its own.
Now the amplification, which is the number that matters. Average followers, say 300. Fan-out on write means every post becomes 300 inbox writes, so 1,000 posts a second becomes 300,000 inbox writes a second, peaking near a million. That number drives the design, and it is three hundred times larger than the one you would have quoted if you stopped at "1,000 posts per second".
The tail is worse than the average. One account with 30 million followers posting once produces 30 million writes. At 100,000 inbox writes a second of spare capacity, that single post takes five minutes to deliver and blocks everyone else's posts behind it. The average hides this.
Reads: 200 million users opening the app 10 times a day is 2 billion timeline reads a day, roughly 20,000 per second, peak 60,000. Each read returns maybe 50 posts.
Storage: 100 million posts a day at ~300 bytes is 30 GB a day for posts, which is small. The inboxes are the cost: 100 million posts times 300 followers is 30 billion inbox entries a day, and at ~30 bytes each that is about 900 GB a day, growing with posts times followers. Bound it by capping each inbox at the most recent few hundred entries.
So the hard part here is write amplification, and specifically its tail. Not the post rate, not storage, not read throughput. The design that follows is aimed at the gap between 300 followers and 30 million.
What an interviewer is ticking off in this stage
- Calculated posts per second
- Multiplied by average followers to get the real write rate: the amplification
- Calculated the same figure for the largest account and noticed it dominates
- Calculated timeline reads per second
- Noted that inbox storage is bounded by capping inbox length
- Finished the sentence naming write amplification and its tail
The numbers, drawn
So the hard part is fan-out amplification, and specifically its tail. The average says 300x; the worst case says 30 million, and the worst case is what takes the system down.
High-level design
about 12 minWhy this stage exists: Because now — and only now — you have a reason for every box you are about to draw.
The core decision is push versus pull. Fan-out on write (push) precomputes each user's inbox when the author posts: reads are one sequential lookup, which is what a 20,000-reads-per-second budget wants, and writes are amplified 300x. Fan-out on read (pull) stores posts once and assembles the timeline by querying everyone you follow at read time: writes are trivial, reads are 300 queries plus a merge, which at 20,000 reads a second is impossible. Neither works alone, so use both.
The hybrid. For ordinary accounts, push: fan out on write into follower inboxes. For accounts above a threshold, say 100,000 followers, do not fan out at all. Their posts stay in their own timeline, and at read time each user's feed is their precomputed inbox merged with the recent posts of the handful of huge accounts they follow. Most users follow only a few such accounts, so that merge is a small, cacheable read. The huge accounts' recent posts are the most cacheable data in the system, because millions of people want the same rows.
Justification by number: the 100,000 threshold comes from the amplification maths. Below it, a post costs at most 100,000 writes, about a second of the fleet's spare capacity; above it, the job would monopolise the pipeline. It is a tunable number, and I would tune it by watching queue latency rather than by picking a round figure and defending it.
The inbox itself: a list per user, keyed by user id, holding post ids and timestamps only. Storing post content there would duplicate every post hundreds of times and make an edit a rewrite of every copy. Capped at the most recent 500 entries, because few users scroll past that and unbounded inboxes are how this design runs out of disk. Partitioned by user id, so one user's timeline is one partition and one sequential read. Held in a store built for this shape: a wide-column store or Redis lists, not a relational table with an index.
The write path: post accepted and written to the post store, with a fan-out event written in the same transaction via an outbox. A relay publishes it. Fan-out workers read the event, look up the author's followers, and write inbox entries in batches, idempotent on post id, because these workers will retry. Justified by the Stage 2 branch where a job dies partway through.
The read path: timeline service reads the user's inbox (cached), merges in recent posts from the large accounts they follow (also cached), sorts, then hydrates post ids into full posts from a post-content cache. Hydration is a batch get, not 50 individual lookups.
The author's own post: written to their own inbox synchronously, before the response returns. This costs one extra write and removes a common complaint in this kind of product: posting something and not seeing it.
Counts (likes and replies) do not live in the post row. They are batched and aggregated separately, because a popular post would otherwise turn into a single contended row taking thousands of increments a second.
What an interviewer is ticking off in this stage
- Chose a hybrid and explained why neither push nor pull works alone
- Named a follower threshold and justified it with the amplification number
- Inbox holds post ids, not post content, and is capped
- Inbox partitioned by user id so a timeline read is one partition
- Fan-out event written via an outbox in the same transaction as the post
- Fan-out workers are idempotent on post id
- Author's own post written synchronously to their own inbox
- Post hydration is a batch lookup, not N lookups
- Counts kept off the post row
The architecture, drawn
Deep dive and tradeoffs
about 14 minWhy this stage exists: Because naming what a decision costs is the single clearest signal of seniority there is.
Hard part one: the huge-account path, in detail. Above the threshold nothing is fanned out, so at read time I need the recent posts of every large account a user follows. I keep a per-author recent-posts list, capped at the last few hundred, and cache it aggressively. This is the highest-value cache in the system: a large account's recent posts are requested by millions of users, so the hit rate approaches 100% and one row serves a huge amount of traffic. Cost: a reader sees a new post from a large account only once that cached list refreshes, so its visibility depends on the cache TTL rather than on fan-out. I accept that, because the alternative is a five-minute fan-out.
Merging the two sources correctly is the fiddly part. The inbox is ordered by time; so are the pulled lists. I merge and take the top N by timestamp. Pagination cannot be an offset, because the two sources shift independently. It has to be a cursor holding the timestamp and post id of the last item shown, and the next page asks both sources for items older than that cursor. Cost: a cursor is more work than an offset and cannot express "jump to page 5", which nobody wants in a feed anyway.
Hard part two: fan-out backpressure. If the queue backs up, timelines silently get stale, and because nothing errors, the team may not notice until users complain. Defences: separate queues by priority so a celebrity backfill cannot delay ordinary posts; alert on queue age rather than queue depth, because age is the number that maps to user pain; and scale workers off backlog growth rather than absolute size. If it backs up badly, I would drop fan-out for inactive users (someone who has not opened the app in 60 days does not need their inbox maintained in real time) and rebuild their timeline via the pull path when they return. Cost: a returning user's first load is slower, which is a good trade against delaying everyone else.
Inactive users are worth raising unprompted. Maintaining inboxes for accounts that never open the app can be a large fraction of total fan-out work, and it produces nothing. Skipping them is one of the biggest savings available here, and the cost is the slower first load above.
Hot key on read: a very large account's recent-posts row is read by millions. One cache key means one hot node. I would replicate that key across several cache nodes with a suffix and read a random one. Cost: N times the memory for those keys and slightly more complex invalidation. Both are small, since there are only a few thousand such accounts.
Consistency, said per feature: the timeline is eventually consistent and may be seconds behind, which is fine and matches how users perceive it. The author's own post is read-your-own-writes, guaranteed by the synchronous self-write. Like counts are eventually consistent and approximate. Follows are strongly consistent, because unfollowing someone and still seeing their posts is a trust problem, not a latency one. So the follow write is synchronous, and the read path checks the follow graph before showing inbox entries.
Deletion, revisited: a deleted post is tombstoned, filtered at hydration time, and its inbox entries are removed lazily. Cost: for a few seconds the post id sits in inboxes and is filtered out on read, which means a timeline can return 49 items instead of 50. Acceptable, and much cheaper than chasing millions of rows synchronously.
What an interviewer is ticking off in this stage
- Detailed the large-account read path and why its cache hit rate is near perfect
- Merge and cursor-based pagination across two sources
- Fan-out backpressure: priority queues, alert on age not depth
- Skipping fan-out for inactive users, and the cost of doing so
- Handled the hot cache key with key replication
- Gave a per-feature consistency answer rather than one blanket statement
- Handled deletion through tombstones and lazy cleanup
- Named a cost after every decision
The decision this turns on
- —Reads are one sequential lookup of a precomputed list. Fast and cheap.
- —Write cost is multiplied by the follower count.
- —A huge account turns one post into millions of writes and blocks the queue.
- —Storage grows with posts times followers, not with posts alone.
- —Writes are trivial: store the post once and stop.
- —Reads query every account you follow and merge, which is hundreds of lookups.
- —Perfect for huge accounts: their posts are read by millions, so cache hit rates are near 100%.
- —Hopeless at 20,000 timeline opens a second for ordinary users.
And then they push back
A complete answer is where the interview starts, not where it ends. These are the questions this design invites — the interview is decided here far more often than in the diagram.
One user has 200 million followers. What happens?
"The fan-out would be slow but it would eventually finish." It would, and by then the post is hours old and every other user's timeline was blocked behind it. Treating this as a latency inconvenience rather than a systemic failure misses the point.
"One post becomes 200 million writes. At my fan-out capacity that is minutes, and worse, it occupies the pipeline so everyone else's posts are delayed behind one person's. So above a follower threshold I stop fanning out entirely — their posts stay in place and readers pull them at read time, merged with their normal inbox. That works because a huge account's recent posts are the most cacheable data in the system: millions of people want the same few rows, so the hit rate is near perfect. Cost: two code paths for one feature forever, a threshold that needs tuning, and timelines assembled from two sources so pagination needs a cursor rather than an offset."
10x traffic tomorrow. What fails first?
"We would add more servers and scale horizontally." This says nothing. Every system has a first thing to break, and not naming it means you do not know your own design well enough to have found it.
Name a specific component and the reason. "The stateless services scale out fine, so they are not the constraint. The database primary is, because every write goes through one machine and I am at roughly 40% of its write capacity now. So at 4x I am at the ceiling, and I would see write latency climb and connection pool waits before anything errors. The second thing is the cache, because 10x reads on a fixed working set is fine but 10x on a growing key space means my hit rate drops and the misses land on the database that is already the constraint. In order: database writes, then cache hit rate, then the connection pool. And I would find out from p99 write latency, not from errors."
The queue is backed up by an hour. What do you do?
"Add more consumers." Sometimes right, sometimes wrong: if consumers are failing and retrying, adding more accelerates the failure. Not distinguishing the two cases is the tell.
"First I work out which of two things it is, because the fixes are opposite. If consumers are healthy and simply outnumbered, scale them out, and check what they are hitting, because often the ceiling is a database and adding workers just moves the queue there. If consumers are failing and retrying, more consumers makes it worse; I find the poison message or the broken dependency and stop the retry storm. Then I triage rather than drain in order: if urgent and non-urgent work share a queue, the non-urgent is now delaying the urgent, which is a lesson for the design, not just the incident. Then I decide what to drop — an hour-old 'your driver is arriving' notification is worse than useless and I would discard it rather than deliver it. And afterwards I alert on message age and backlog growth rate, not depth, because depth crossing a threshold tells you an hour too late."
A user updates their profile and immediately sees the old version. How do you fix it without slowing everyone down?
"Make all reads go to the primary." That works, and it throws away the benefit of having replicas. You have solved one user's problem by removing your read scaling.
"Scope the fix to the user who wrote. Options in increasing precision: pin that session to the leader for a short window after a write, which is simple and costs a little leader load; or record the write position at commit and have their subsequent reads wait for a replica that has reached it, which is exact and means reads can block briefly; or pin them to a single replica so they at least see a monotonic view, which is cheap and prevents flickering between old and new. For a profile edit specifically, the cheapest correct answer is to render the response from the write itself rather than re-reading — the server already knows the new state. Everyone else continues reading from replicas with normal lag, which is invisible to them because they were not the one who changed it."
Why a queue instead of just calling the service directly?
"Queues decouple services and are more scalable." True in the abstract and it does not explain why this particular call needs one. Queues also add duplicate delivery, ordering questions and debugging difficulty, and not mentioning those means you added one by reflex.
"Because the user does not need to wait for this work to finish, and because the downstream cannot absorb my spikes. Concretely: sending the receipt email takes 800 ms and can fail, and there is no reason to make the customer wait for it or to fail their order because a mail provider is down. The queue absorbs a 10x spike into a longer backlog instead of a broken service. What it costs: the customer gets 'received' rather than 'done', so the product has to be honest about that; every consumer must be idempotent because delivery is at-least-once; and a failure now happens in a worker minutes later, which is harder to debug. I would not queue the stock check or the payment, because the user needs a real yes-or-no answer before they leave the page. A queue there adds latency and buys nothing."
What does this cost per month?
"I am not sure, it depends on the provider." Interviewers rarely want exact prices. They want to know whether you understand which line item dominates, and that is answerable from your own estimates.
"I would not guess at prices, but I can tell you the shape, which is what matters. The dominant cost here is egress bandwidth: my estimate said 1,350 GB a second to viewers, and at that volume nothing else on the bill comes close. Second is storage, because I keep raw data for two years and it grows every day whether anyone reads it or not. Third is compute, which is elastic and therefore the easiest to control. That ordering tells me where to spend engineering effort: a percentage point of CDN hit rate is worth more than any amount of compute optimisation, so cache hit rate is the number I would put on a dashboard and defend. Storage cost I would attack with lifecycle policies moving cold data to cheaper tiers and with tiered granularity. Compute I would run on interruptible capacity because the work has hours of slack."
Which alert fires first when this system starts failing?
"We would alert on CPU and memory." Those are symptoms of the machine, not of the user experience, and they fire either far too late or constantly for no reason.
"I would alert on user-visible symptoms first and causes second. The first alert should be error rate or latency at p99 on the main user path, because that is what maps to someone being unhappy. Then a small number of leading indicators that fire before users notice: queue age (not depth), because age maps directly to how late the work is; replication lag; cache hit rate dropping, which precedes database saturation; and connection pool wait time, which is the earliest sign of a slow dependency. For this system specifically, the earliest signal is usually cache hit rate, because everything downstream is sized assuming the cache absorbs most reads. I would not page on CPU. It is a dashboard metric, not an alert, because a machine at 90% CPU serving requests fine is not an incident, and a machine at 20% CPU timing out is."
Now make it multi-region.
"We would replicate the database to another region." That covers reads. The question is what happens to writes, and that is where the design actually changes.
"Reads are the easy half: replicas in each region, users read locally, and they may be slightly stale — I would say how stale and which reads cannot tolerate it. Writes are the hard part, and there are three options. Single write region: simplest, no conflicts, and users far away pay 150 ms per write, which is fine if writes are rare and unacceptable if they are not. A home region per data partition, so each user's data lives near them: local writes without conflicts, but it needs a routing layer plus a plan for users who move. Multi-leader with conflict resolution: local writes everywhere, and now I own a conflict problem, which is only acceptable where merges are safe. I would pick the second for user-owned data and single-region for anything globally contested like inventory. What changes elsewhere: cross-region replication cost, a failover story per region, and data residency rules that may force certain data to stay in certain places. I would ask about that constraint rather than discover it later."
The topics this answer used
You have read a good answer. That is worth very little until you can produce one on a blank page — so go and write this problem without looking, and compare afterwards.