The simplest Twitter feed: when a user opens their timeline, the server queries tweets from every followed user, merges and sorts them. Demonstrates why O(following_count) database queries per feed read makes fanout-on-read unworkable at scale.
Designing a Twitter-like news feed is one of the most frequently asked system design interview questions because it forces candidates to reason about the fundamental trade-off between read cost and write cost. The naive fanout-on-read approach is where every candidate should start — it establishes the baseline that makes the improvements in fanout-on-write architectures measurable and concrete.
The core challenge is delivering a personalized timeline: each user sees a chronological feed of tweets from accounts they follow. In the naive approach, this is computed on every read. When a user opens their feed, the server (1) queries the follows table to get all followed user IDs, (2) fetches recent tweets from each followed user, (3) merges and sorts by timestamp, and (4) returns the top 20. At 200 average follows per user, this means approximately 201 database queries per feed request.
The pull model's fatal flaw is the read amplification. Each feed request generates O(following_count) database queries. At 500 concurrent users with 60% feed reads and 200 average follows, the database handles 60,000 queries per second — far beyond what a single PostgreSQL instance can sustain. The connection pool saturates, feed latency climbs from 150ms to multiple seconds, and the system hits a hard ceiling. Meanwhile, the write path is trivially cheap: posting a tweet is a single INSERT.
This asymmetry between read cost and write cost is the key insight interviewers are looking for. In a social feed workload, reads outnumber writes by 100:1 or more. The naive approach puts all the cost on the read side (O(N) queries per read, O(1) per write), when the optimal strategy is to shift cost to the write side (O(1) per read, O(N) per write via fanout-on-write). The Fanout-on-Write variant demonstrates this reversal: pre-computing feeds at write time reduces reads to a single Redis LRANGE in 2ms.
This template makes the read amplification visible and quantifiable. Run the simulation at increasing user counts and watch the database become the bottleneck while the service layer sits idle. The comparison with the Fanout-on-Write and Hybrid variants provides the concrete numbers to support the discussion of read/write trade-offs that interviewers expect.
Twitter feed design appears in interviews at Twitter/X, Meta, Threads, Bluesky, Mastodon, and virtually every company with a social product. Interviewers expect candidates to start with the pull model, identify the O(N) read problem, and propose fanout-on-write as the first optimization — then discuss the whale problem that motivates the hybrid approach.
The naive Twitter feed system is a four-component linear architecture: Client, Load Balancer, Tweet Service, and PostgreSQL database. There is no cache, no event stream, no fanout workers, and no separation between the read and write paths.
All traffic enters through the Load Balancer, which distributes requests across Tweet Service pods using round-robin. The Load Balancer adds approximately 1.5ms of routing latency and supports up to 5,000 RPS — well above the system's actual ceiling, which is determined by the database. Both feed reads and tweet posts flow through the same LB and service — there is no CQRS split.
The Tweet Service is a stateless REST API running on 3 pods with 50 threads each. It handles four operations: (1) feed read — query the follows table for all followed user IDs, then fetch recent tweets from each followed user, merge-sort by timestamp, return top 20; (2) tweet post — validate and INSERT into the tweets table; (3) profile view — query a single user's profile and recent tweets; (4) follow/unfollow — INSERT or DELETE in the follows table. The service processing time is approximately 8ms, but feed read latency is dominated by the O(following_count) database queries.
PostgreSQL stores three tables: tweets, users, and follows. The tweets table is the hottest — it receives INSERTs on every tweet post and is the target of O(N) SELECT queries on every feed read. A B-tree index on (author_id, created_at DESC) supports per-author tweet queries but causes write amplification on every INSERT. The follows table is queried on every feed read to determine who the user follows. The users table stores profile data and is rarely a bottleneck.
The system has no redundancy at the data layer. A single PostgreSQL primary handles all reads and writes. If the database fails, both feed reads and tweet posts stop entirely. There is no cache, so every feed read hits the database with hundreds of queries. There is no deduplication, no event stream, and no pre-computation.
The concrete scaling ceiling is approximately 500 concurrent feed readers. At this point, 300 feed reads per second x 200 queries each = 60,000 database queries per second. The connection pool (100 connections) saturates, feed latency degrades from 150ms to multiple seconds, and tweet write latency spikes as writes compete with reads. The system degrades gracefully until approximately 1,000 users, where connection pool exhaustion causes outright errors.
This sequence diagram shows the two primary flows: tweet posting (cheap O(1) write) and feed reading (expensive O(N) read). The critical insight is the read amplification — each feed request fans out to N database queries where N is the number of users the requester follows. At 200 follows, each feed read triggers 201 queries. Most of the system's capacity is consumed by this read fan-out.
Step-by-Step Walkthrough
Pseudocode
// Tweet post — O(1) write, trivially cheap
async function postTweet(author_id, content):
tweet_id = generateSnowflakeId()
await db.execute(
"INSERT INTO tweets (tweet_id, author_id, content, created_at)
VALUES ($1, $2, $3, now())",
[tweet_id, author_id, content]
) // ~50ms (INSERT + B-tree index update)
return { status: 201, tweet_id }
// Feed read — O(N) queries, the bottleneck
async function getFeed(user_id, cursor):
// Step 1: Get followed user IDs
followed = await db.execute(
"SELECT followed_id FROM follows WHERE follower_id = $1",
[user_id]
) // ~15ms, returns ~200 user IDs
// Step 2: Fetch recent tweets from each followed user
all_tweets = []
for each uid in followed: // ~200 iterations
tweets = await db.execute(
"SELECT * FROM tweets WHERE author_id = $1
ORDER BY created_at DESC LIMIT 20",
[uid]
) // ~15ms per query
all_tweets.extend(tweets)
// Step 3: Merge-sort and return top 20
all_tweets.sort(by: created_at, desc: true)
return all_tweets[:20]
// Total: ~201 queries, 150ms-3s depending on parallelismChoice
Compute the feed from scratch on every read request
Rationale
Fanout-on-read is the simplest possible approach — no background workers, no event stream, no cache to manage. The feed is always fresh and instantly reflects follow/unfollow changes. The cost is O(following_count) database queries per feed read. At 200 follows, each feed request triggers 201 queries. The Fanout-on-Write variant pre-computes feeds to deliver O(1) reads.
Choice
One database for tweets, users, and follows
Rationale
A single PostgreSQL instance eliminates replication lag, split-brain, and failover complexity. ACID transactions ensure data consistency. The cost is that fan-out read queries and tweet writes compete for the same resources — connection pool, buffer cache, WAL bandwidth. This mutual degradation is the primary scaling bottleneck.
Choice
Every feed read hits PostgreSQL directly
Rationale
A Redis cache for pre-computed feeds would eliminate the O(N) read problem entirely — a single LRANGE returning 20 tweet IDs in 2ms. The naive approach skips caching for simplicity. Adding a cache is the core insight of the Fanout-on-Write variant and often the first optimization interviewers expect candidates to propose.
Choice
No Kafka, no async fanout, no write-time pre-computation
Rationale
An event stream would enable fanout-on-write — when a tweet is posted, workers push the tweet ID into each follower's cached feed. The naive approach skips this because there is no cache to fan out to. The result is a system where writes are O(1) but reads are O(N).
Choice
Single service handles both reads and writes
Rationale
Without separate read and write paths, the system cannot scale them independently. Feed reads (60% of traffic, O(N) queries each) compete with tweet writes (15% of traffic, O(1) each) for the same service threads and database connections. CQRS separation is introduced in the Fanout-on-Write variant.
Target RPS
~500 sustained (DB ceiling)
Latency (p99)
150ms-3s feed reads (varies with follow count)
Storage
~5 GB/month at modest scale
Availability
~99% (single DB, no redundancy)
| Operation | Time | Space | Notes |
|---|---|---|---|
| Post tweet (POST /tweets) | O(1) INSERT + O(log N) index update | O(1) per tweet (~1KB with index) | N = tweets by author. Index update cost grows logarithmically. This is the cheap path. |
| Read feed (GET /feed) | O(F * log T) where F = following count, T = tweets per author | O(F * K) merge buffer, K = tweets per author | F = ~200 average. Each of the F queries does an index seek (log T) plus fetch. This is the expensive path. |
| Follow/Unfollow (POST /follow) | O(1) INSERT or DELETE | O(1) per follow edge | Takes effect immediately on the next feed read since feeds are computed on-the-fly. |
Stores all tweet content. The hottest table: INSERT on every tweet post, and O(following_count) SELECT queries on every feed read. Each feed request queries this table once per followed user. B-tree index on (author_id, created_at DESC) enables per-author queries but adds write amplification.
Indexes: idx_tweets_author_time ON (author_id, created_at DESC)
At 150 tweets/sec, table grows ~5 GB/month with indexes. Each feed read triggers ~200 index seeks on this table.
User profile data. Write-once on account creation, read on profile views. Small table, not a bottleneck.
Indexes: idx_users_username ON (username)
Static data — rarely written after account creation.
Social graph edges (follower_id, followed_id). Queried on every feed read to get the list of followed user IDs. Also queried for follower/following counts on profile views. Composite index enables efficient lookups in both directions.
Indexes: idx_follows_follower ON (follower_id, followed_id), idx_follows_followed ON (followed_id, follower_id)
Grows proportionally with total follow edges. At 100K users x 200 avg follows = 20M rows.
| Variant | Tier | Latency | Throughput | Cost | Complexity | Reliability |
|---|---|---|---|---|---|---|
| Naive (Fanout-on-Read) | T1 | 150ms-3s feed reads | ~500 concurrent users | $500/month (single DB) | Low — no cache, no workers | 99% (single DB, no failover) |
| Fanout-on-Write (Push) | T2 | <5ms feed reads (Redis) | 200K+ feed reads/sec | $3,000/month (CQRS + Redis + Kafka) | Medium — Kafka, Redis, workers | 99.9% (replicated components) |
| Hybrid Push+Pull (Whale-Aware) | T3 | <10ms feed reads (dual-cache merge) | 1M+ feed reads/sec | $4,500/month (dual cache + workers) | High — whale detection, dual cache | 99.9% (replicated, whale-resilient) |
This template is for educational and illustration purposes only. It may not represent the optimal production design for this problem. Real-world systems involve additional considerations (compliance, specific cloud provider constraints, organizational requirements) not captured here. Use this as a starting point for discussion, not as a production blueprint.
The Twitter feed combines asymmetric read/write patterns, the fan-out problem, caching strategy, event streaming, and the whale problem in a single question. It forces candidates to reason about the read-write trade-off: should you compute feeds at read time (simple writes, expensive reads) or at write time (expensive writes, simple reads)? Companies like Twitter/X, Meta, Threads, Bluesky, and Mastodon ask it because it maps directly to their production challenges.
At 500 concurrent users with 60% feed reads and 200 average follows, each feed read triggers ~201 database queries. That is 300 feeds/sec x 201 queries = 60,300 queries/sec on a single PostgreSQL instance. The database can handle approximately 10,000-20,000 queries/sec, so it saturates at around 500 users. The connection pool (100 connections) becomes the hard bottleneck — queries queue behind each other, feed latency climbs to seconds.
Pre-compute feeds at write time using fanout-on-write. Instead of computing the feed from scratch on every read (O(N) queries), pre-compute it when a tweet is posted by pushing the tweet ID into each follower's cached feed in Redis. This reverses the cost structure: writes become O(N) but reads become O(1) — a single Redis LRANGE in 2ms. Since reads outnumber writes by 100:1, this is a massive net win.
Posting a tweet is a single INSERT into the tweets table — O(1) regardless of the author's follower count. A user with 50 million followers costs exactly the same to write as a user with 5 followers. There is no fanout, no cache update, no event publish. This is the one advantage of fanout-on-read: writes are trivially cheap. The Fanout-on-Write variant trades this simplicity for O(1) reads.
In the naive approach, follow/unfollow changes take effect immediately because feeds are computed from the current follows table on every read. In the Fanout-on-Write approach, unfollowing requires purging the unfollowed user's tweets from the cached feed — an additional background operation that may take seconds. This is one area where the naive approach is genuinely better, though the trade-off is not worth the O(N) read cost.
Sign in to join the discussion.
Ready to design your own Twitter Feed?
Open the simulator, place components on the canvas, wire them up, and run a traffic simulation to see how your architecture performs under real load.
Open Simulator