The first is fan-out on read, the day-one query grown up. Nothing is prepared in advance. When you open the app, we look up the hundred or so accounts you follow, ask each one for its recent posts, merge them by time, and hand you the top twenty. A post is visible the instant it's written, because there's no delivery step to wait for. Following and unfollowing take effect immediately, because the follow list is read fresh every time. And there's no extra machinery at all. It's one query against stores you already have.
Now price a single read. It's roughly a hundred lookups in the follow data, then a hundred queries to the post store, then a merge of a hundred little lists. All of that has to finish inside a five-millisecond budget, while the person stares at a spinner. Multiply by 300k reads a second, and the post store is being asked for 30M queries a second, every one on somebody's latency budget. And notice who pays the most. The cost of your read scales with how many accounts you follow, so the more you use the product, the worse it gets for you. That's a strange thing to build on purpose.
The second answer is fan-out on write. Do the work when the post is written, not when it's read. The moment somebody posts, copy that post's id into a ready-made list belonging to every one of their followers. Now a read is one lookup: fetch your list, it's already assembled, done. The cost is on the other side. One post no longer means one write. The average post goes to about seventy-five followers, so one post means about seventy-five writes, and 6k posts a second becomes about 450k timeline writes a second. You're also doing work for followers who won't open the app today, and a post isn't visible until its copies have landed.
Fan-out on write — build every list when the post is written. Fan-out on write. Pay at write time, read for free. Put the two costs next to the ratio from the brief: fifty reads for every write. Fan-out on write does about seventy-five units of work once to build the lists, then one cheap lookup on each of those fifty reads. That's seventy-five to build plus fifty to serve, about a hundred and twenty-five units all in. Fan-out on read instead does about a hundred units on every one of those fifty reads, and does it from scratch each time. Same post, same readers, about forty times more work. You get fifty chances to collect on work you did once, and that's what decides it.
How it works, following one post through. The post service stores the post, then puts one message on the fan-out queue, about 6k a second. That's the last thing it does. A fan-out worker takes the message off the queue and reads the author's follower list from the follow graph. Then it writes the post's id into each follower's list in the timeline cache. One message becomes seventy-five writes, so this is where the volume lives, at about 450k timeline writes a second. The read path is separate, and it runs whether anybody is posting or not: the timeline service fetches your ready-made list and hands it back. One lookup, and it never touches the queue or the workers.
One detail on the read path: how you get the next page. Your timeline is newest-first, and a page is twenty posts. The obvious way to get the next twenty is to skip the ones you already saw. That breaks twice. Skipping over everything you've already seen to return twenty means the work grows the further you scroll, so page ten costs ten times page one. And the list changes while you read it. New posts arrive at the top, so 'skip the first twenty' now points somewhere else, and you see one post twice and miss another. Use a cursor instead. A cursor is just the id of the last post you saw. 'Give me twenty posts older than this one' is a position in the list, not a count into it, so it costs the same on every page. The list moves; your place in it doesn't.
Why there's a queue at all. It's not for throughput. 6k messages a second is nothing to a queue. It's there so the person posting doesn't wait. Their post is durable the moment the post store has it, and that's when their request finishes. Delivering it to seventy-five timelines is somebody else's job, and it has five whole seconds to happen. If the worker fleet has a bad minute, delivery lags and nothing is lost, because the queue is holding every message. Loss turns into lag, and lag is the one thing the requirements allow.
That's also what picks the product. At 6k messages a second, throughput decides nothing. All three candidates handle it, so durability picks the winner. Redis pub/sub is fastest and simplest, and it loses immediately: it forgets a message once delivered, so worker downtime loses those posts. That breaks the one promise this queue exists to keep. RabbitMQ is durable and acknowledges each message, but it deletes a message once acknowledged, so there's no going back, and the routing it's good at we don't use. Kafka keeps every message for days after it's read. That buys us what the others can't: if we ship a bug that writes timelines wrong, we fix the worker and replay the last two days. For a box whose whole job is durability, being able to rewind beats routing we'll never use.
How the queue spreads the work. Kafka splits a topic into partitions, and each worker owns some of them. We partition by post id, so consecutive posts land on unrelated partitions and the work spreads flat. The tempting alternative is to partition by author id. That would keep one author's posts in order. It would also send every post from our 50M-follower account to the same partition, which is a hot partition we'd then have to fix. We don't need per-author order anyway. Each post's fan-out is an independent job, and the timeline is sorted when it's read, not when it's written. So post id it is, and the celebrity problem stays where it belongs, for later.
Where the multiplication happens, and it's the most common sizing mistake here. The queue carries about 6k messages a second. The workers emit about 450k writes a second. The amplification is inside the workers, not the queue. Size the queue at 450k and you've misread the write path. One Kafka broker sustains about a hundred thousand appends a second, so our 6k runs at a few percent of one. Now capacity. A message is a post id, an author id, and a little overhead, so call it a hundred bytes. 6k a second is about fifty gigabytes a day. Three days of replay, times three copies, is about half a terabyte across the brokers. That's a rounding error.
What happens when the queue fails. Each message is replicated across brokers, so a broker dying loses nothing that was acknowledged. The harder question is what happens if the queue won't take the message at all. By then the post is already durable in the post store, so the poster still succeeds. The only thing we've lost is the delivery. The post row gets a flag saying its delivery never got queued, and a background sweep re-drives those posts once the brokers are back. That sweep is a few lines of code, not a box on this board. It's the difference between losing a post and delaying one, which is the promise the brief made.
The workers. They're stateless and sized by the work. One worker batches its writes to the cache. It has a whole follower list to write at once, so it pipelines them, and that gets it to roughly 10k timeline writes a second. 450k divided by 10k is forty-five, so call it a few dozen workers, and add more when the number moves. If one dies mid-message, the queue redelivers it, so the same post can be written into the same timeline twice. That's free to handle. A timeline entry is keyed by the post's id, so writing it a second time overwrites the first. Writing it twice and writing it once are the same thing.
The follow graph. Step two read 'the author's follower list' from a box called the follow graph, and that name is a trap worth walking into on purpose. The word graph pulls hard toward a graph database, or something like Meta's TAO, and this is where a lot of good candidates spend five minutes. So ask what we actually do with it. We ask one question: give me the followers of this account. That's it. We never walk the graph. We never ask for friends-of-friends, or the shortest path between two people, or anything else that makes a graph a graph. We read one account's list of followers, by that account's id. A question like that is a row lookup, and a row lookup is answered by a table.
So the follow graph is two plain tables, sharded. One is keyed by the account being followed, so 'who follows this person' is one shard's job. The other is keyed by the follower, so 'who does this person follow' is one shard's job too. Which product? The same one the post store uses: MySQL, sharded. It's a separate cluster, because its shard key and traffic differ, but there's nothing new to learn or operate. That's the point, and it's why this box carries no technology name at all. Every other box earns its name by being chosen. This one is a table, and a table is what you already have. Even Meta's TAO is a cache in front of MySQL, serving the graph walks we never do.
Two operations the follow graph owes, because the brief asked for follow and unfollow. Follow a new account and its old posts aren't in your timeline yet, since fan-out only copies future posts. So a follow kicks off a one-time read of that account's recent posts, merged into your list once. Unfollow is the reverse problem: their posts are already in your list, keyed by you, with no cheap way to pick out just theirs. So we don't hunt them down. The timeline service drops posts from accounts you no longer follow as it serves the page, and they age out of the cache on their own. Both stay cheap, and neither needs a new box.
Size it both ways. Capacity first. Half a billion accounts following about a hundred each is on the order of tens of billions of follow relationships. We store each one twice, once per table, so about a hundred billion rows. A row is two ids and a little overhead, call it twenty bytes. That's about two terabytes, and three copies is six, still a handful of machines. Now throughput. One node serves about 10k point lookups a second. We read this box once per post, 6k reads a second, less than one machine's worth. Take the bigger, the handful. It's the least stressed thing on the board, doing the job people reach for a specialized database to do. Hold on to those two numbers. What comes next changes one of them by fifty times, and the answer moves.
Before we move on, look hard at the number that argument stands on, because the whole next problem is hiding inside it. The average post goes to about seventy-five timelines, which is Twitter's disclosed deliveries a day divided by its disclosed posts a day. Now remember the gap from the brief. Seventy-five is an average, and an average is a summary. This one is summarizing accounts whose followers run to the tens of millions, which is roughly 670k times the average. Everything above says fan-out on write is obviously correct, and for almost every account it is. What comes next is about the accounts where it's a catastrophe.