The problem statement is: "Design a news feed system like Facebook — users can publish posts and see a ranked feed of posts from people they follow." That is deliberately ambiguous. The first job is to narrow it to a workable scope and surface the constraints that will drive every subsequent decision.
The most important scope call in this step is the follow model. One-way follow (Twitter-style: User A can follow User B without B following back) vs. two-way friendship (Facebook-style: both must accept). This matters enormously for the fan-out logic — one-way follow allows celebrities with millions of followers, which changes the architecture fundamentally. Two-way friendship has natural limits on fan-out because the social graph is denser but smaller per user.
The second critical call is whether feed is ranked or chronological. Chronological is trivial to implement — just sort by timestamp. Ranked requires an engagement model, a ranking service, and a more complex read path. Agreeing on ranked up front sets the expectation that step 5 will go deep on ranking logic.
| Requirement | Decision |
|---|---|
| Publish post | Users can post text, images, and video. Media stored in object store (S3/OSS). Focus is on the feed, not media pipeline. |
| Read feed | Ranked feed of posts from people you follow. Not purely chronological — engagement signals drive ordering. |
| Like / comment | Exists in the system (drives ranking signals) but not designed in detail today. Out of deep scope. |
| Follow model | One-way follow, Twitter-style. User A follows User B without B needing to accept. No mutual follow requirement. |
| Follower limit | No hard limit. Small percentage of users (celebrities) can have millions of followers. This drives the hybrid fan-out. |
| Privacy / mute | Out of scope. All posts visible to all followers. No hidden posts, no mute. |
| Notification | In scope as an analytics/tracking service, not as push notifications to devices. |
| Media pipeline | Out of scope. Object store (S3) assumed for media. Not designing CDN or transcoding today. |
| DAU | 300M daily active users |
| Posts/day | 5M new posts per day across all users |
| Availability | High — feed should always be readable even if slightly stale |
| Consistency | Eventual consistency acceptable. A new post can take 3–5 seconds to appear in all followers' feeds. This is a key product decision that enables the async fan-out design. |
| Feed latency | Feed should load fast — initial posts from cache within milliseconds, full ranked feed within ~500ms |
| Feed size | 20 posts per API call, infinite scroll. Prefetch triggered at post 10. |
Agreeing that 3–5 seconds of lag is acceptable is not a minor detail — it unlocks the entire async fan-out architecture. If you required strong consistency (post visible to all followers within milliseconds), you'd need synchronous fan-out, which at celebrity scale means millions of synchronous writes blocking every post. The 3–5 second window is what allows Kafka + async fan-out service to work. Always surface this tradeoff explicitly in requirements rather than assuming it.
For a news feed, personas matter more than they did for TinyURL because the user type directly drives the architectural split. Two fundamental user behaviors produce two completely different system paths.
- Has ≤ ~1M followers
- POST /feed/posts
- Fan-out service pushes to all followers' feeds
- Feed table written per follower
- Has > 1M followers
- Same POST /feed/posts
- Fan-out is SKIPPED — too expensive
- Pull model on read path instead
- Opens feed multiple times daily
- Likely to have warm cache
- Infinite scroll — prefetch at post 10
- Worth pushing their feed proactively
- Opens feed infrequently
- Cache cold on arrival
- Not worth wasting fan-out writes for
- Pull model on demand instead
The celebrity threshold (push vs pull based on follower count) is the textbook answer. But there is a subtler extension: inactive followers should also not receive fan-out writes. If a user hasn't opened their feed in 30 days, writing their userId+postId to the feed table on every post from every person they follow is pure waste — they may never read it. The notification/analytics service tracks activity and marks users accordingly. Active flag drives whether fan-out service writes to that user's feed. This is a real production optimization that most textbook answers miss.
Estimation starts from DAU and post frequency and chains through to the numbers that constrain architecture. The key insight for news feed: this is a massively read-heavy system. The ratio of reads to writes is not 10:1 like TinyURL — it's more like 100:1 because every active user opens their feed multiple times per day, but only a fraction of users post anything.
Media storage is the number that surprises people. At 10MB per post (assuming video), the numbers are enormous. The session corrected this to 1MB average after accounting for the fact that most posts are text or images, not video. Even at 1MB average, media storage over 10 years is measured in petabytes — which is why object storage (S3, OSS) is mandatory, not optional. You cannot put 18PB in a relational database.
The session initially assumed 10MB per post (accounting for video), which produced 50TB/day and over 180PB over 10 years. That's not wrong — it's technically valid for a worst-case assumption. But it's worth sanity-checking: what fraction of posts actually contain video? If it's 10%, then average post size is closer to 1MB, and the 10-year storage drops to 18PB. The sanity check habit matters — producing an obviously unreasonable number and not questioning it is a signal that you're calculating without reasoning.
Write QPS of 58 is low — a single application server handles thousands of QPS. Writes are not the bottleneck here. Read QPS of 5,800 is where the system must be optimized. The 100:1 ratio means every architectural decision should ask: "does this make the read path faster?" Caching, pre-computation, feed table design, ranking service placement — all driven by the read asymmetry.
Two core endpoints cover the full functional scope: one for publishing, one for reading. The publish endpoint is straightforward. The read endpoint has one important detail — pagination.
| Method + Path | Request | Response |
|---|---|---|
| POST /api/v1/feed/posts | Body: userId, optional text, optional imageUrl[], optional videoUrl |
{ postId, createdAt } — 201 |
| GET /api/v1/feed | Query: userId, limit=20, cursor=<lastPostId> |
{ posts[], nextCursor } — 200 |
Infinite scroll requires the client to tell the server where it left off. Cursor-based pagination uses the ID of the last post seen as the starting point for the next call. This is far superior to offset-based pagination for infinite scroll because:
- New posts don't shift results: If you're on "page 3" using offset and 5 new posts arrive, your page 3 is now different. With a cursor, you always get the next 20 posts after the one you last saw — no drift.
- Works with Cassandra's data model: Cassandra supports efficient range queries by partition key + clustering key. A cursor maps directly to a Cassandra range query. Offset-based pagination requires a full scan and skip — inefficient at scale.
- First call behavior: On first load, cursor is null — return the most recent posts. After each scroll, cursor = the last postId returned. If the app is closed and reopened, cursor resets to null and the feed starts fresh from the top.
The cursor lives in client memory — browser localStorage or app state. The server is stateless — it doesn't know which page a user is on. This is intentional: stateless servers can be replaced or scaled without coordination. The client always sends its current position to the server, and the server always returns the next batch. The client owns the scroll state; the server owns the data.
The news feed system has two completely different paths: the write path (publishing a post) and the read path (loading a feed). They share infrastructure but are optimized for opposite workloads — write for reliability and async throughput, read for speed and pre-computation. Drawing and explaining both paths separately is essential.
- Post Service — receives the post, writes to post cache and post DB synchronously, then drops ONE event into Kafka. Returns success to the user immediately. Does not wait for fan-out to complete. This is what keeps post latency low regardless of follower count.
- Kafka (post.published topic) — decouples post service from fan-out service. The event carries userId and postId. Kafka provides durability, ordering within a partition, and fan-out service can be scaled by adding more consumers without changing post service.
- Fan-out Service (Kafka consumer) — consumes the event, queries Graph DB to get the list of followers, applies the active/inactive filter (skip inactive users to avoid waste), checks if user is celebrity (skip fan-out if >1M followers), then writes one record per eligible follower into the Feed Table.
- Graph DB — stores the social graph: who follows whom. Fan-out service queries this to get the follower list for a given userId. Separate from user DB because graph traversal queries are different in structure from relational queries.
- Feed Table — the most important table in the system. Schema:
(userId, postId, createdAt). Indexed by userId. Fan-out service writes one row per follower per post. The get service reads from here on the read path.
Phase 1 — Instant from cache: The get service immediately queries the feed cache for this user's pre-computed feed. This returns within milliseconds. The user sees their first posts almost instantly. This handles the initial scroll without any database calls.
Phase 2 — Async enrichment: Simultaneously, an async job fetches from the feed DB (Cassandra) to get the full set of recent posts for regular friends. In parallel, the get service queries Graph DB to identify which followees are celebrities (high-volume users, flagged in user table). For each celebrity in the user's follow list, it calls the Hybrid Get Service, which fetches recent posts directly from the post cache. These celebrity posts are recent enough to almost always be cache-hot. Both result sets — regular friend posts from feed DB + celebrity posts from post cache — are sent to the Feed Ranking Service.
Feed Ranking Service: Receives the merged pool of posts from both sources, applies the engagement scoring model (see deep dive 5B), sorts by score, and returns the top 20. This ranked result is also written back to the feed cache so the next scroll is even faster.
The pull model for celebrities only works efficiently because their posts are almost guaranteed to be in post cache. A celebrity with 10M followers generates millions of read requests for the same post. That post will be at the top of the LRU cache indefinitely. Pulling it on the read path is not expensive — it's a single cache hit per celebrity per feed load. If the celebrity had 100 followers, they'd get fan-out like everyone else. The inversion only makes sense at celebrity scale.
The fan-out problem is the central challenge of news feed design. When a user posts, every one of their followers needs to see it. At small scale this is trivial. At Facebook scale — where a single celebrity can have 100 million followers — doing this synchronously would take hours and generate millions of writes in a second.
The key insight is that fan-out is not urgent. Users can wait 3–5 seconds for a post to appear in their feed — we agreed to this in requirements. This acceptable lag is what enables the entire async architecture. If the requirement were "post visible within 100ms to all followers," the design would be fundamentally different and much harder.
The session debated this threshold — it started at 500 followers, moved to 1M. The right number depends on your fan-out service capacity and your SLA. At 58 write QPS for posts, with an average of say 500 followers each, fan-out service handles 29,000 feed writes per second. That's manageable. But one celebrity with 100M followers posting would generate 100M writes — taking hours even with parallelized workers. The threshold is the point at which fan-out becomes a capacity problem, not a latency problem. 1M is a reasonable starting point that can be tuned.
Before writing to the feed table, the fan-out service filters out inactive followers. A user who hasn't opened the app in 30 days doesn't need their feed pre-populated — when they return, their first request will trigger a fresh pull from the feed DB. Skipping inactive users reduces fan-out volume significantly (inactive users are often a large fraction of total followers) and avoids polluting the feed table with records that will never be read. The notification/analytics service maintains the active/inactive flag in the user table, updated from analytics events.
- Kafka consumer group: Fan-out service runs as multiple consumer instances in a Kafka consumer group. Each post event is processed by exactly one consumer. Adding more consumers (horizontal scaling) increases fan-out throughput without changing post service.
- Batch writes to Feed Table: Instead of writing one row at a time to Cassandra for each follower, batch multiple writes into a single Cassandra batch. This significantly reduces write overhead at high follower counts.
- Async fan-out within fan-out: For users with large but sub-threshold follower counts (say 500K followers), the fan-out service can spawn parallel worker threads to write to feed table concurrently rather than serially, completing within seconds.
Once the read path has assembled posts from two sources — regular friend posts from the feed table and celebrity posts from the post cache — something must merge them into a single ranked list of 20. This is the feed ranking service's job.
The key question is: what does "ranked by relevance" actually mean in implementation? Purely chronological is easy — sort by timestamp. But Facebook's feed and Twitter's algorithm are not chronological. They surface posts the user is more likely to engage with, which means the system needs a signal about engagement between users.
The approach discussed: maintain a per-user-pair engagement score. Every time User A likes, comments on, or shares User B's post, increment a counter stored in the user relationship record (or a separate engagement table). This score reflects the strength of User A's interest in User B's content. Higher score = higher ranking for B's posts in A's feed.
score(post) = engagement_score(viewer, author) // how much viewer likes this author × recency_weight(post.createdAt) // newer posts score higher × post_engagement(likes + comments) // popular posts score higher // engagement_score: incremented on like, comment, share // decays over time — interaction 6 months ago matters less than last week // stored in graph DB on the user-follows-user edge
The feed ranking service receives two lists:
- List A: Posts from regular friends — fetched from feed table (Cassandra), already sorted by createdAt. Maybe 50–200 posts from the last 24 hours.
- List B: Posts from celebrities the user follows — fetched from post cache via hybrid get service. 5–10 celebrity posts from recent hours.
The ranking service combines both lists, scores each post using the engagement model, sorts descending by score, and returns the top 20. The remaining posts (position 21+) are discarded — the next scroll call will re-fetch and re-rank with updated scores and newer posts.
Separation of concerns. The get service owns data retrieval — know where the data is and fetch it fast. The ranking service owns ranking logic — know how to score and sort posts. Keeping them separate means ranking algorithm changes don't require touching the get service. The ranking model will change frequently as product intuitions evolve — it should be independently deployable. At scale (Meta, Twitter), the ranking service is a sophisticated ML system with its own team. Starting with a simple engagement score is the right MVP, but the architecture should not couple ranking logic with data retrieval.
Infinite scroll works best when the next batch is ready before the user reaches the bottom. When the user reaches the 10th post of their current 20, the app triggers a background prefetch call to fetch posts 21–40. These arrive before the user finishes reading posts 11–20, making the scroll feel seamless. The get service writes the prefetched batch to the feed cache tagged by cursor position. This is a client-side trigger — the app knows when to fire it, the server just processes it like any other feed request.
This system uses five different databases — each chosen for a specific access pattern. The instinct to consolidate to fewer databases is understandable (operational simplicity), but each choice is justified by the workload. Using the wrong database type for a workload doesn't just cost performance — it can make certain queries functionally impossible at scale.
The critical distinction to understand first: OLTP vs OLAP. These are two completely different types of database read workloads, often confused because both are described as "read-heavy."
- ▸ "Give me this user's 20 posts"
- ▸ Single row or small set lookup
- ▸ Must return in <50ms
- ▸ Runs 5,800 times/sec
- ▸ Row-oriented storage optimal
- ▸ Use: SQL, Cassandra, Redis
- ▸ "How many posts in last 30 days by region?"
- ▸ Aggregations across millions of rows
- ▸ Seconds to minutes acceptable
- ▸ Runs infrequently (batch jobs)
- ▸ Columnar storage optimal
- ▸ Use: Snowflake, BigQuery, OCI ADW
During the session, the feed table was initially suggested as a candidate for a data warehouse (ADW). This is incorrect. The feed table is queried 5,800 times per second with requests like "give me all postIds for userId=X, ordered by createdAt, limit 20." That is a point lookup — single partition key, small result set, must return in milliseconds. A data warehouse is columnar-optimized and scans entire columns to reconstruct a row — it's too slow for this pattern. Cassandra is the right choice: partitioned by userId, clustered by createdAt, sub-millisecond range queries within a partition.
| Table | DB Type | Why |
|---|---|---|
| User | SQL (PostgreSQL) | Structured, ACID needed for account management (updates, deletes, email uniqueness). Low write volume. Complex queries (find by email, status filter). |
| Post | Document DB (MongoDB) | Posts are semi-structured — variable length text, optional arrays of media URLs. Document DB handles flexible schema naturally. Not suited for SQL's rigid column definition. |
| Feed | NoSQL (Cassandra) | High frequency key lookups by userId. No joins. Needs horizontal scale. Must handle 5,800 reads/sec. Cassandra partitioned by userId, clustered by createdAt — perfect fit. |
| Social Graph | Graph DB (Neo4j) | Who follows whom is a graph traversal query. "Get all followers of userId X" maps naturally to graph DB. Would require expensive self-joins in SQL or denormalization in NoSQL. |
| Analytics Events | ADW / Columnar (OCI ADW, BigQuery) | Batch analytics, nightly aggregations, cross-user reporting. Low frequency, high row count, complex aggregations. This IS an OLAP workload. Columnar storage optimal. |
Each database is chosen for a specific access pattern. Mixing them adds operational complexity, but each choice is justified by the workload. In a large organization, each database is owned by a different team. For a smaller system, you'd consolidate — probably SQL for user + post, one NoSQL for feed, graph either in NoSQL or a simple adjacency list in SQL, and skip the dedicated analytics DB until you need it. The right answer is not "always use X" or "always minimize database types" — it's to match storage to access pattern and scale to actual needs.
Five tables, five different database types. Each table's schema is driven by the access patterns defined in the design — not by habit or convenience.
| Column | Type | Notes |
|---|---|---|
| id | BIGINT | Primary key. Auto-increment or Snowflake-style. |
| username | VARCHAR(50) | Unique. Indexed. |
| VARCHAR(255) | Unique. Indexed. Used for login. | |
| follower_count | INT | Denormalized counter. Used by fan-out service to check celebrity threshold. Updated on every follow/unfollow. |
| is_celebrity | BOOLEAN | Derived flag. True when follower_count > 1M. Fan-out service checks this to decide push vs pull. |
| is_active | BOOLEAN | Updated by analytics service. False = user hasn't opened app in 30 days. Fan-out service skips inactive users. |
| last_active_at | TIMESTAMP | Updated on each login/feed fetch. Used to compute is_active. |
| created_at | TIMESTAMP | Account creation time. |
| Field | Type | Notes |
|---|---|---|
| _id | ObjectId | MongoDB auto-generated ID. Used as postId throughout the system. |
| userId | String (ref User) | Author of the post. Required — missing in initial design, added after feedback. |
| text | String | Post text content. Optional (media-only posts allowed). |
| mediaUrls | Array[String] | URLs pointing to S3/OSS objects. Array because a post can have multiple images. External URLs, not blob data. |
| likeCount | Int | Denormalized counter for ranking. Incremented on like events. |
| commentCount | Int | Denormalized counter for ranking. |
| createdAt | Date | Post creation time. Used for recency scoring in ranking. |
| Column | Type | Notes |
|---|---|---|
| userId | UUID | Partition key. All feed records for one user live on the same Cassandra node. Enables single-node range queries. |
| createdAt | TIMESTAMP | Clustering key (DESC). Records within a partition sorted newest-first. Enables efficient "get latest N posts" without full scan. |
| postId | UUID | Reference to Post table. Not storing full post — just the ID. Feed table is a pointer table, not a data store. |
Storing full post content in the feed table would make it enormous. Every post gets written to potentially thousands of feed table rows (one per follower). If each row stored 1KB of post content, a celebrity post with 1M eligible followers would write 1GB of duplicated data. By storing only the postId (16 bytes), the feed table stays small and fast. The get service fetches the actual post content from post cache/post DB in a second call, which is fast because it's indexed by postId. Feed table = index. Post table = data.
| Node / Edge | Properties | Notes |
|---|---|---|
| User node | userId, username | Each user is a node in the graph. |
| FOLLOWS edge | followerId, followeeId, createdAt, engagementScore | Directed edge. A follows B = edge from A to B. Engagement score lives on this edge — incremented when A engages with B's content. Used by ranking service. |
| Column | Type | Notes |
|---|---|---|
| eventId | UUID | Auto-generated. |
| userId | BIGINT | Who triggered the event. |
| eventType | VARCHAR | FEED_LOAD, POST_CREATED, POST_LIKED, POST_COMMENTED, APP_OPEN, APP_CLOSE. |
| deviceType | VARCHAR | mobile, web, tablet. |
| geoLocation | VARCHAR | Country/region for geo analytics. |
| timestamp | TIMESTAMP | Event time. |
A nightly batch job aggregates this raw event data into summary tables: active user counts by region, post engagement rates, feed load latencies by device type. These summaries feed the analytics dashboard and update the is_active flag in the User table. The engagement score on FOLLOWS edges in Graph DB is also updated from this pipeline — likes and comments in the analytics events translate into edge weight increments.
The post caching strategy (TTL, eviction policy, sizing) was not detailed. The social graph DB query pattern for getting follower lists at scale was discussed but not deep-dived. The ranking ML model (we used a simplified engagement counter — production would use a trained model with many more signals). Rate limiting on the post API (preventing spam) was mentioned but not designed.