News Feed (Facebook / Instagram style) System Design Interview Question
Problem: Design a news feed system that shows a user the latest posts from people they follow.
Overview
A news feed is the most-hit screen in a social app: roughly 300M daily active users open it ten times a day, which works out to ~35K feed reads per second at average and ~100K at peak. The problem is not rendering twenty posts — it is deciding whose twenty posts to render without scanning every post every followee has ever made. The book splits the system into two halves: feed publishing (a post is written and distributed into followers' timelines) and feed building (a user opens the app and reads a precomputed list). The interesting design pressure is the long-tailed follower distribution. A typical user has 200 followers; a celebrity has tens of millions. Naively pre-materializing timelines on every write means one celebrity post costs 100M writes, while materializing on read means every feed GET fans in across potentially thousands of authors. The hybrid model below is the book's answer to that asymmetry.
Summary
A news feed system for ~300M DAU split into two halves the book defines explicitly: (1) feed publishing — a post service + fanout service that writes a new post and populates follower news-feed caches — and (2) news feed building — a news feed service that reads the precomputed cache and hydrates posts. The core tradeoff the book walks through is fan-out-on-write vs fan-out-on-read: pure on-write breaks on celebrity posts (a 100M-follower post = 100M writes), while pure on-read makes feed GET require N fan-ins of all followees' posts. The recommended solution is a hybrid — fan-out-on-write for regular users (precomputed timelines in Redis) and fan-out-on-read for celebrities with >1M followers, merged at read time for that small set. Backed by user/post/graph stores with aggressive caching (news feed cache, post cache, user cache), and a notification service that can ping followers about new content.
Requirements
Functional
- Create a post with text and optional media
- View a paginated feed of posts from followed users, newest first
- Follow and unfollow users with immediate effect on the feed
- Cursor-based pagination stable across new posts arriving
- Notify followers (optional) when a followed user posts
Non-functional
- P99 feed read latency under 200 ms at 100K QPS peak
- Post write visible to most followers within 5 seconds
- 99.95% availability for the feed read path
- Cache hit ratio > 95% on timeline and post caches
- Horizontally scalable to 300M DAU
Capacity Assumptions
- 300M DAU
- Average user follows 200 others, posts 2x/day
- Average user opens feed 10x/day (3B feed reads/day)
- 1% of users are 'celebrities' (>1M followers) producing 20% of posts
- Feed page = 20 posts; retained post window = 2 weeks
Back-of-Envelope Estimates
- Post writes: 300M * 2 / 86400 ≈ 7K QPS (peak 20K)
- Feed reads: 300M * 10 / 86400 ≈ 35K QPS (peak 100K)
- Fan-out writes (regular users only): 6K QPS * 200 avg followers ≈ 1.2M fanout writes/sec
- Timeline cache size: 300M users * 20 post ids * 24B ≈ 150 GB in Redis
High-level architecture
The write path starts at a post service that persists the post to a sharded Postgres/MySQL cluster (user_id as shard key), pushes the post_id onto a fanout queue (Kafka), and returns 202 immediately so the user sees their post instantly via optimistic UI. A fanout worker consumes the queue, looks up the author's followers via the user-graph service, and branches: if the author has fewer than 1M followers it iterates the follower list and LPUSHes the new post_id into each follower's Redis timeline (capped at ~1000 entries), which is cheap because the median author has ~200 followers. If the author is a celebrity (>1M followers), the worker skips fanout entirely and the author's posts are pulled at read time — this trades a tiny merge cost on every feed read for avoiding the pathological 100M-write burst. The read path is a news-feed service that, on GET /feed, first fetches the user's precomputed timeline IDs from Redis, then merges in any celebrity authors the user follows by querying their latest posts from a post-by-author index, then hydrates post and user details through a read-through post cache (Redis) and user cache, and finally returns a cursor-paginated page. Consistent hashing places each user's timeline and post shard together so fanout writes and feed reads stay local. The tradeoff is a hybrid code path that has to be tuned: getting the celebrity threshold wrong turns a clean design into either a write storm or a read storm.
Architecture Components (11)
- Client (iOS / Android / Web) (client) — Mobile/web client that posts and pulls feed pages.
- Load Balancer (lb) — L7 LB with sticky routing by user id for cache locality.
- News Feed Service (api) — The book's 'news feed service' (read side). Reads precomputed news-feed cache + merges celebrity posts at read time, then hydrates post bodies + author info from caches.
- Post Service (Feed Publishing) (api) — The book's 'feed publishing' entry point. Handles post creation, validation, stores the post, and kicks off fanout + notification.
- Post Store (nosql) — Source-of-truth post content, keyed by post_id.
- Fanout Queue (Kafka) (queue) — Durable log of fanout jobs to decouple post write from timeline writes.
- Fanout Service (worker) — The book's 'fanout service' — reads fanout events, looks up followers, prepends post_id to each follower's news-feed cache (fan-out-on-write). For celebrities it skips the write entirely (fan-out-on-read).
- User Graph Service (api) — Serves follower / following edges. Read-heavy.
- Timeline Cache (Redis Sorted Sets) (cache) — Per-user precomputed timeline of recent post ids.
- Post + User Cache (cache) — Two logical Redis caches the book ch11 calls out explicitly for the read path: post cache (post_id → body+meta) and user cache (user_id → display name, avatar, follower count).
- Notification Service (api) — Sends a push/email 'X posted' ping to followers who opted in. Called by the post service (or the fanout service) on new post creation.
Operations Walked Through (4)
- post — POST /posts — post service writes to Post Store, enqueues fanout + notification. Fanout service asynchronously prepends the post_id into each follower's news-feed cache.
- fanout — Async: fanout service reads the event, pages through followers, and ZADDs into each follower's news-feed cache. Book's default path for non-celebrity users.
- celebrity-fanout — A celebrity with 80M followers posts. Fanout service skips the write; at read time, the news feed service merges the celebrity's posts from a dedicated celeb-posts sorted set. Book's hybrid solution to the write-amplification problem.
- feed — GET /feed — news feed service reads the precomputed timeline, merges any celebrity posts, and hydrates post + author details from caches.
Implementation
package com.systemdesign.newsfeed.model;
import java.time.Instant;
import java.util.List;
public final class FeedItem {
private final long postId;
private final long authorId;
private final String authorName;
private final String text;
private final List<String> mediaUrls;
private final Instant createdAt;
private final int likeCount;
private final int commentCount;
public FeedItem(long postId, long authorId, String authorName, String text,
List<String> mediaUrls, Instant createdAt, int likeCount, int commentCount) {
this.postId = postId;
this.authorId = authorId;
this.authorName = authorName;
this.text = text;
this.mediaUrls = mediaUrls;
this.createdAt = createdAt;
this.likeCount = likeCount;
this.commentCount = commentCount;
}
public long getPostId() { return postId; }
public long getAuthorId() { return authorId; }
public String getAuthorName() { return authorName; }
public String getText() { return text; }
public List<String> getMediaUrls() { return mediaUrls; }
public Instant getCreatedAt() { return createdAt; }
public int getLikeCount() { return likeCount; }
public int getCommentCount() { return commentCount; }
}
package com.systemdesign.newsfeed.service;
import com.systemdesign.newsfeed.model.FeedItem;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
@Service
public class FeedService {
private static final int CELEBRITY_THRESHOLD = 1_000_000;
private static final int TIMELINE_CAP = 1000;
private final TimelineCache timelineCache; // Redis LIST per user
private final UserGraphClient graph; // followers / following
private final PostRepository postRepo; // sharded by author_id
private final PostCache postCache; // Redis HASH per post
@Autowired
public FeedService(TimelineCache timelineCache, UserGraphClient graph,
PostRepository postRepo, PostCache postCache) {
this.timelineCache = timelineCache;
this.graph = graph;
this.postRepo = postRepo;
this.postCache = postCache;
}
/** Write path: called from the fanout worker once per new post. */
public void onNewPost(long authorId, long postId) {
long followerCount = graph.followerCount(authorId);
if (followerCount >= CELEBRITY_THRESHOLD) {
return; // pull model on read
}
for (long followerId : graph.followers(authorId)) {
timelineCache.prepend(followerId, postId, TIMELINE_CAP);
}
}
/** Read path: return up to `limit` items older than `cursor`. */
public List<FeedItem> buildFeed(long userId, long cursor, int limit) {
List<Long> pushIds = timelineCache.range(userId, cursor, limit);
List<Long> celebrityIds = new ArrayList<>();
for (long authorId : graph.following(userId)) {
if (graph.followerCount(authorId) >= CELEBRITY_THRESHOLD) {
celebrityIds.addAll(postRepo.latestPostIds(authorId, cursor, limit));
}
}
List<Long> merged = new ArrayList<>(pushIds);
merged.addAll(celebrityIds);
return merged.stream()
.distinct()
.map(postCache::hydrate)
.sorted(Comparator.comparing(FeedItem::getCreatedAt).reversed())
.limit(limit)
.toList();
}
}
package com.systemdesign.newsfeed.api;
import com.systemdesign.newsfeed.model.FeedItem;
import com.systemdesign.newsfeed.service.FeedService;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/v1/feed")
public class FeedController {
private final FeedService feedService;
public FeedController(FeedService feedService) {
this.feedService = feedService;
}
@GetMapping
public ResponseEntity<Map<String, Object>> getFeed(
@AuthenticationPrincipal Long userId,
@RequestParam(defaultValue = "0") long cursor,
@RequestParam(defaultValue = "20") int limit) {
if (limit > 50) limit = 50;
List<FeedItem> items = feedService.buildFeed(userId, cursor, limit);
long nextCursor = items.isEmpty()
? 0
: items.get(items.size() - 1).getPostId();
return ResponseEntity.ok(Map.of(
"items", items,
"nextCursor", nextCursor,
"hasMore", items.size() == limit));
}
}
Key design decisions & trade-offs
- Fanout model — Chosen: Hybrid push for regular users, pull for celebrities. Pure push breaks on 100M-follower posts; pure pull makes every feed GET fan in across thousands of authors. The hybrid localizes the hot path while keeping most reads cheap. Cost: two code paths and a tunable threshold.
- Timeline cache store — Chosen: Redis LIST per user, capped at 1000. Fixed memory per user (300M users * 1000 post_ids * 24B ≈ 7 TB), O(1) prepend, O(N) range for pagination. Losing entries beyond the cap means deep scrolling falls back to DB, which is an acceptable tail.
- Pagination — Chosen: Cursor-based using post_id. Offset pagination gets unstable when new posts arrive between pages. A monotonic cursor gives stable, deep pagination and plays well with Redis LRANGE and DB range scans.
- Write acknowledgment — Chosen: 202 accepted after Kafka enqueue, not after fanout. Fanout can take seconds for a large follower list; blocking the user on it would make posting feel slow. The cost is that a follower can refresh faster than the worker propagates — optimistic UI on the author hides it.
- Post storage — Chosen: Sharded relational DB keyed by author_id. Posts are mostly read by (author_id, time) for the pull path and by post_id for hydration — both served well by a relational shard. Co-locating an author's posts keeps celebrity pull-reads on one shard.
Interview follow-ups
- Add ranking — replace strict chronological ordering with a scored feed (engagement, recency, affinity)
- Handle unfollow — lazy cleanup vs immediate invalidation of the timeline cache
- Design a realtime 'new posts available' banner without polling
- Extend for stories (24h TTL) and reels (vertical video) on the same feed
- Cross-region replication so a feed read in EU does not cross the Atlantic