Design Instagram
News Feed & Media System Design
Overview
Open Instagram and two things happen almost instantly: your home feed loads with photos from people you follow, and a freshly uploaded Reel starts playing seconds after you posted it. Both feel effortless, but each hides a genuinely hard system design problem at scale. Serving the feed is a fan-out problem — if you have 400 million followers and post a photo, do you push that post into 400 million feed caches immediately, or do you force every follower to pull it at read time? The media pipeline is a storage and async-processing problem — a raw video file cannot be served directly; it must be transcoded, thumbnailed, and distributed to a CDN before it is renderable.
It is labeled "Hard" because the naive answers to both problems collapse under Instagram's scale (500M daily active users, ~100M photos and videos uploaded per day). Pure fan-out on write for the feed is the wrong choice when a celebrity with 400M followers posts — not because it is physically impossible, but because it creates an intolerable inconsistency window (the first followers see the post while follower #399,999,999 waits minutes) plus a write-amplification spike that overwhelms the cache write tier. Pure fan-out on read is impractical when a feed contains 500 followees — assembling a feed by merging 500 individual post lists at query time is far too slow. And storing raw video blobs in a relational database is the fastest way to melt your storage layer. A strong answer reaches for a hybrid feed strategy (fan-out on write for regular users, fan-out on read for celebrities, merged at read time) and a fully async media pipeline (presigned upload → queue → transcoder → CDN).
In this walkthrough you'll scope the system, size it, model the data, design the API, draw the read and write paths, and then go deep on the media upload pipeline, the hybrid feed generation strategy and the celebrity problem, and the follow graph with feed cache consistency.
Out of scope (say so): Stories, Explore/Reels recommendation ranking, Direct Messages, ads targeting, comment threading beyond a simple count, and the notifications pipeline. We keep the core: upload a photo or video, follow users, view a home feed, like and comment on a post.
Functional requirements
- Upload a post. A user uploads one or more photos or a short video with a caption. The media is processed and eventually visible on followers' feeds.
- Follow / unfollow a user. A user follows another; their future and recent posts appear in the follower's feed.
- Home feed. A user loads a paginated feed of recent posts from the users they follow, roughly reverse-chronological.
- Like a post. A user likes a post; a like count is shown on the post (eventually consistent is fine).
- Comment on a post. A user adds a text comment; comments are shown below a post with a count.
Out of scope (state it): Stories, Explore/Reels ranking, DMs, push notifications, ads, search, account signup flow.
Non-functional requirements
- Read-heavy. Feed loads vastly outnumber post uploads. The read path must be fast, cached, and never block on write work.
- Media upload is the highest-latency action and must be async. Raw video transcoding takes seconds to minutes; it must never block the upload HTTP response or the media serving path.
- Feed load must be low-latency (< 200 ms p99 for the metadata; media streams independently via CDN). Assembling a feed from 500 followees at read time is too slow — precomputed or cached feeds are the default.
- Celebrity / high-fanout problem. A user with 400M followers cannot fan out a post to all feed caches synchronously. The system must handle extreme follower counts without stalling.
- Media availability and durability. Uploaded media must be durable (multi-region replication in blob storage) and available globally (CDN edge caching). A failed CDN edge must fall back to origin.
- Eventual consistency is acceptable. A like count that is a few seconds stale, or a post that appears in a follower's feed a few seconds after posting, is fine. Exact ordering is not required.
- Availability over strong consistency. A degraded feed (slightly stale or missing recent posts) is better than an error page.
Estimations
State assumptions first; the purpose is to justify design choices, not to produce exact numbers.
Rounded assumptions
- DAU: ~500M. A user opens the app ~5–10×/day — so roughly 2–5B feed loads/day, or ~25–60K feed loads/sec at peak.
- Posts per day: ~100M (photos + videos), so ~1,200 posts/sec at peak.
- Read:write ratio: roughly 50:1 — feed reads dominate heavily over uploads.
- Followers per user: average ~300–500; celebrities have tens to hundreds of millions.
- Media size: average photo ~3 MB (after compression); short video ~50 MB raw, transcoded to a few MB per bitrate. Raw upload ingestion: 100M posts/day × ~30 MB average raw = ~3 PB/day into the blob store before transcoding. After transcoding/compression, the retained processed variants may be smaller than raw for many videos but still land in the multi-PB/day range at this scale. This is order-of-magnitude sizing; the key takeaway is: never put media in the DB.
- Feed cache size: storing 500 post ids per user × ~80 bytes per sorted-set member (64–90 bytes in Redis — the 8-byte raw id plus per-member overhead) × 500M users ≈ ~20 TB — substantial but shardable across a Redis cluster.
- Likes/day: assume 10× posts → ~1B likes/day, ~12K likes/sec. Heavily skewed to popular posts.
How the numbers affect the design
| Signal from the numbers | Design decision |
|---|---|
| 25–60K feed reads/sec, read:write ~50:1 | Precompute feeds into a cache; reads are cache hits, not DB scans. |
| 1,200 posts/sec × avg 400 followers = 480K feed-cache writes/sec (normal users) | Fan-out on write is cheap for normal users; it's what makes reads fast. |
| A celebrity post × 400M followers = 400M feed-cache writes | Fan-out on write creates an intolerable inconsistency window and write spike for celebrities — fall back to fan-out on read, merged at query time. |
| ~3 PB/day raw upload ingestion; multi-PB/day retained variants | Never store blobs in the DB; use a blob store + CDN. Upload via presigned URL direct from client. |
| ~12K likes/sec, skewed to hot posts | Hot-key problem on like counters — buffer in Redis, flush to DB asynchronously. |
Caution
Do not store media blobs in a relational database. A single video file is 10–100 MB; at 100M uploads/day, your database would explode within days. Keep only a URL/key pointer in the DB row; store the actual bytes in a dedicated blob/object store (S3-style) served through a CDN.
Core entities / data model
Six things are stored: the user, the post (metadata + media pointer, not the bytes), the follow edge, the feed cache (precomputed per user), likes, and comments.
| Entity | Key fields |
|---|---|
User |
user_id, username, profile_pic_url, follower_count, following_count, created_at |
Post |
post_id, user_id, media_url (CDN URL), media_type (photo/video), caption, like_count, comment_count, created_at |
Follow |
(follower_id, followee_id), created_at — directed edge; also store (followee_id, follower_id) for reverse lookup |
FeedCache |
user_id → ordered list of post_ids (reverse-chronological, capped ~500–1000 entries) |
Like |
(post_id, user_id), created_at — composite primary key prevents duplicate likes |
Comment |
comment_id, post_id, user_id, text, created_at |
The Post row stores a CDN URL (media_url), never the bytes. The media processing pipeline (deep dive 1) writes the CDN-accessible URL back to the post row once transcoding completes. Before that, the post exists in a processing state; clients can show a spinner until the status flips to published.
The Follow edge is directed (following is not necessarily mutual) and stored both directions: (follower_id, followee_id) to answer "who am I following?" and (followee_id, follower_id) to answer "who follows me?" — the latter is what the fan-out worker reads to build the list of feed caches to push into (deep dive 3). Partition by follower_id for the forward direction and followee_id for the reverse.
The FeedCache is a derived structure — never the source of truth. It is a precomputed, ordered list of post ids per user. If it is lost or corrupted, it can be rebuilt from the follow graph and the post store. Capped at ~500–1000 entries because clients rarely paginate beyond the first few screens.
Caution
Do not treat the FeedCache as authoritative. The post store and follow graph are the sources of truth. The feed cache is a performance optimization that accelerates reads — if it drifts or is lost, you rebuild it; you cannot rebuild the posts from the cache.
Where each lives. User and Post metadata live in a sharded relational DB (Postgres, sharded by user_id/post_id). Follow edges live in a sharded wide-column / KV store partitioned as described above (or in a separate graph store). FeedCache lives in Redis, keyed by user_id, implemented as a sorted set with created_at as the score. Like rows live in a sharded DB or wide-column store; the running like_count on the post row is an eventually-consistent counter, buffered in Redis and flushed periodically (deep dive 2). Comments live in a sharded DB.
API design
Six operations carry the system: media upload (two-step), post publish, feed read, like, and comment.
Step 1 — Request a presigned upload URL
POST /api/posts/upload-url
Body: { "media_type": "video", "file_size_bytes": 52428800 }
201 Created
Returns: { "upload_url": "https://s3.../...", "upload_id": "uuid", "expires_at": "..." }
Returns a short-lived presigned URL the client uses to upload directly to the blob store. The server never proxies the bytes — they go straight from the client to S3. The upload_id tracks the upload's progress through the processing pipeline.
Step 2 — Publish a post after upload completes
POST /api/posts
Body: { "upload_id": "uuid", "caption": "sunset vibes", "media_type": "photo" }
202 Accepted
Returns: { "post_id": "...", "status": "processing" }
Creates the Post row in processing state and enqueues the media-processing job. Returns 202 because transcoding is asynchronous. The client can poll GET /api/posts/{post_id}/status or receive a webhook/push notification when the status flips to published.
Get home feed
GET /api/feed?cursor=<last_post_id>&limit=20
200 OK
Returns: { "posts": [...], "next_cursor": "..." }
Returns a paginated list of post metadata (including CDN media URLs) for the caller's home feed. Served from the precomputed FeedCache in Redis for normal users, or assembled on the fly by merging followee post lists for users following many celebrities (deep dive 2). Uses cursor-based pagination (the post_id as cursor, since it encodes time — post_id should be a time-sortable id such as Snowflake or ULID) — never offset-based, which breaks when new posts are inserted.
Like a post
POST /api/posts/{post_id}/likes
204 No Content
Records the like. Atomic conditional insert of (post_id, user_id) — idempotent; a second like is a no-op. Increments a Redis counter for like_count; the counter is periodically flushed to the post row in the DB (deep dive 2).
Add a comment
POST /api/posts/{post_id}/comments
Body: { "text": "beautiful!" }
201 Created
Returns: { "comment_id": "...", "created_at": "..." }
Inserts a comment row and increments comment_count similarly to likes.
Get post detail (comments + likes)
GET /api/posts/{post_id}
200 OK
Returns: { post, "like_count": 14032, "comment_count": 87, "comments": { "items": [...], "next_cursor": "..." } }
Returns full post detail. like_count is served from the Redis buffer (eventually consistent), comments are paginated from the DB.
High-level architecture
The system has two distinct flows: a media write path (upload → process → store → CDN) and a feed path (post published → fan-out to follower caches → read on demand). We'll walk each, then combine.
1. Read path — home feed
Feed reads are the highest-volume operation. They must be served from precomputed caches, never by re-querying the DB and merging followee lists on the fly.
flowchart LR
Client["Client\n(app)"] -->|"GET /feed"| FeedSvc["Feed Service"]
FeedSvc -->|"read post_ids\n(user's FeedCache)"| FCache[("FeedCache\n(Redis sorted set\nper user)")]
FeedSvc -->|"merge celebrity posts\nat read time\n(see Deep Dive 2)"| PostDB[("Post Store\n(sharded DB)")]
FeedSvc -->|"multi-get post metadata\n(post_ids from cache\n+ celebrity post_ids)"| PostDB
FeedSvc -->|"merged feed\n+ CDN URLs"| Client
Client -->|"stream media"| CDN["CDN\n(edge nodes)"]
CDN -.->|"cache miss: origin fetch"| BlobStore[("Blob Store\n(S3-style)")]
- The Feed Service reads the user's precomputed
FeedCachefrom Redis — an ordered list of ~500 recentpost_ids. This is an O(1) read. - For users who follow celebrities (accounts above the fan-out threshold), the Feed Service also merges recent posts from those celebrity followees at read time — a small extra lookup since the average user follows only a handful of celebrities (see Deep Dive 2 for the full hybrid strategy).
- It does a multi-get on the Post Store to fetch metadata (caption, CDN URL, like count, etc.) for all post ids — both from the cache and from the celebrity merge.
- The response includes CDN URLs for each media item. The client fetches the actual image/video bytes directly from the CDN — never through the Feed Service.
- CDN edge nodes serve cached media. On a miss, the edge fetches from the blob store origin and caches the result.
Tip
The Feed Service returns metadata (text, URLs, counts). It never proxies media bytes. The client fetches media directly from CDN URLs. This decouples the feed API latency from media delivery latency.
2. Write path — media upload and processing
Raw media is uploaded directly to blob storage and then processed asynchronously. The API server never handles the bytes.
sequenceDiagram
participant C as Client
participant API as API Server
participant S3 as Blob Store
participant Q as Processing Queue
participant T as Transcoder Workers
participant FQ as Fan-out Queue
participant CDN as CDN
C->>API: POST /upload-url (media_type, size)
API-->>C: presigned_url + upload_id
C->>S3: PUT raw media (direct upload, bypasses API)
S3-->>Q: event: upload complete (upload_id, s3_key)
Q->>T: transcode job
T->>S3: write processed variants (thumbnails, bitrates)
T->>API: callback: set post status = published, media_url = CDN URL
API->>FQ: enqueue fan-out (post_id, author_id)
Note over CDN,S3: CDN pulls from Blob Store on first viewer request
API-->>C: (push notification or poll: post published)
- The client requests a presigned URL from the API. The API generates and returns it without touching the bytes.
- The client uploads directly to the blob store. The API server is not in the data path.
- The blob store fires an event (S3 trigger / SNS notification) onto the processing queue.
- Transcoder workers pick up jobs, produce all needed variants (thumbnails, multiple video bitrates via HLS/DASH adaptive bitrate), and write them back to blob storage.
- The transcoder updates the post row with the final CDN URL and flips the status to
published. - The CDN serves media lazily — it fetches from the blob store origin on first access and caches the result at the edge.
3. Write path — post fan-out to feed caches
Once a post is published, it must appear in followers' feeds. The fan-out writes post_id into each follower's FeedCache in Redis.
flowchart LR
PostSvc["Post Service\n(publishes post)"] -->|"enqueue fan-out\n(post_id, author_id)"| FanQ[["Fan-out Queue"]]
FanQ --> FW["Fan-out Workers"]
FollowDB[("Follow Store\n(followee→followers\nindex)")] -->|"who follows this user?"| FW
FW -->|"ZADD post_id to\neach follower's FeedCache"| FCache[("FeedCache\n(Redis)")]
FW -.->|"skip if follower\ncount > threshold\n(celebrity)"| Skip["(no cache write;\nfeed assembled on read)"]
- When a post is published, the Post Service enqueues a fan-out job. As an optimization, the Post Service can consult the cached celebrity flag on the author and skip enqueuing entirely for known celebrities — the fan-out worker's follower-count check remains the backstop for accounts that cross the threshold mid-flight.
- Fan-out workers look up the poster's followers using the reverse follow index.
- For each follower, they do a Redis
ZADDon that follower'sFeedCachesorted set, usingcreated_atas the score, and trim the set to the cap (~500–1000 entries). - Celebrity exception: if the poster's follower count exceeds a threshold (e.g. 1M), skip the push entirely. Instead, when a follower loads their feed, the Feed Service merges celebrity posts at read time (deep dive 2 — the hybrid strategy).
4. Combined architecture
flowchart LR
Client["Client"] --> GW["API Gateway\n+ CDN"]
GW -->|"POST upload-url"| API["API Servers"]
API -.->|"presigned URL"| Client
Client -->|"direct upload"| S3[("Blob Store\n(S3)")]
S3 -->|"upload event"| PQ[["Processing Queue"]]
PQ --> TC["Transcoder Workers"]
TC -->|"write variants"| S3
TC -->|"callback: published\n+ CDN URL"| API
API -->|"update post: published\n+ CDN URL"| PostDB[("Post Store")]
API -->|"enqueue fan-out\n(post_id, author_id)"| FQ[["Fan-out Queue"]]
FQ --> FW["Fan-out Workers"]
FollowDB[("Follow Store")] --> FW
FW -->|"push post_id\n(non-celebrity)"| FCache[("FeedCache\nRedis")]
GW -->|"GET /feed"| FeedSvc["Feed Service"]
FeedSvc -->|"read FeedCache"| FCache
FeedSvc -->|"merge celebrity posts\non read"| PostDB
FeedSvc -->|"multi-get metadata"| PostDB
GW -->|"POST like"| LikeSvc["Like Service"]
LikeSvc -->|"incr counter"| LikeRedis[("Like counters\nRedis")]
LikeRedis -.->|"async flush"| PostDB
The upload path, the fan-out path, and the read path are fully decoupled. A viral post's fan-out queue backlog never blocks a feed read. A slow transcoding job never blocks the upload HTTP response.
Deep dives
1. Media upload, storage, processing, and CDN delivery
This is the most distinctive part of Instagram's design relative to a pure text or social-graph system. Every bit of media passes through a pipeline before it is renderable.
Why presigned URLs, not server-side proxying. The naive upload path — client POSTs to your API server, server writes to S3 — means your API servers handle every byte of every photo and video. At 100M uploads/day with videos averaging 50 MB, that is roughly 5 PB/day flowing through your API tier. This saturates network bandwidth, CPU, and memory on your API servers for work that adds no business logic. A presigned URL solves this: the API server generates a signed URL with a short TTL, hands it to the client, and the client uploads directly to S3. The API server stays completely out of the data path. This is the standard approach at every major media platform.
flowchart LR
C["Client"] -->|"1. request presigned URL"| API["API"]
API -->|"2. generate + return URL\n(TTL ~15 min)"| C
C -->|"3. PUT raw bytes\ndirectly to S3"| S3[("Blob Store")]
S3 -->|"4. upload complete event"| Q[["Processing Queue"]]
Q -->|"5. transcode job"| TC["Transcoder\nWorkers"]
TC -->|"6. write variants\n(thumb, 360p, 720p, 1080p)"| S3
TC -->|"7. update post row\nstatus=published\nmedia_url=CDN URL"| DB[("Post Store")]
The processing pipeline. A raw video upload is not renderable. The pipeline must:
- Generate thumbnails — one frame (or user-selected frame) at several resolutions.
- Transcode to multiple bitrates — e.g. 360p, 720p, 1080p in H.264/H.265, packaged as HLS/DASH segments for adaptive bitrate streaming.
- Normalize images — strip EXIF metadata, apply compression to a consistent quality level, and output multiple resolutions (feed thumbnail, profile thumbnail, full view).
All of this runs asynchronously, off the user's request path. The post starts in a processing state; the client shows a spinner or a placeholder. Only after the transcoder writes the final CDN URL and flips the status to published does the post appear in fans' feeds (the fan-out is triggered after published, not on the raw upload).
Caution
Do not transcode synchronously on the upload request. A 1-minute video takes 30–120 seconds to transcode at high quality. Blocking the HTTP response for that duration will time out, frustrate users, and bottleneck your entire upload tier. Transcode asynchronously via a queue; return 202 on the publish request.
Blob storage layout. Organize blobs by user_id/post_id/variant (e.g. u123/p456/thumb_320.jpg, u123/p456/720p.m3u8). This makes it easy to delete all variants when a post is deleted and supports lifecycle policies by user or date prefix. Do not add artificial random prefixes to "shard" S3 — since 2018 S3 auto-partitions by key prefix, so hierarchical user_id/post_id/variant keys carry no performance penalty.
CDN strategy. Serve all media through a CDN — never expose the blob store origin URL directly to clients. CDN edge nodes cache content geographically close to users. A Cache-Control: public, max-age=31536000, immutable header on processed media is safe because a post's CDN URL is content-addressed (includes the post id and variant); if a user updates their profile picture, a new URL is issued rather than invalidating the old one. For the rare legitimate invalidation (DMCA takedown, account deletion), issue a CDN purge by URL prefix — this hits all edges but is infrequent enough to be acceptable.
Warning
Do not serve media directly from S3 origin to all clients. S3 data-transfer costs and latency from a single region would be prohibitive at scale. A CDN handles geographic distribution, reduces origin load by 99%+ on popular content, and absorbs traffic spikes from viral posts.
Worker scaling. Transcode workers are stateless and embarrassingly parallelizable — each job is independent. Use an auto-scaling group of spot/preemptible instances backed by the processing queue. A viral video (many simultaneous views immediately after upload) doesn't overload the transcoder because transcoding happens once; CDN handles the concurrent views.
Storage tiering for old media. Not all posts are viewed equally. A post from 3 years ago is cold storage; it does not need to live on expensive SSD-backed blob storage with hot CDN caching. Implement a lifecycle policy: move blobs older than ~90 days to an infrequent-access tier (S3-IA or Glacier for the coldest content). CDN TTLs for old content can be longer (a year or more) since the content is immutable and virtually never updated.
2. Feed generation — hybrid fan-out and the celebrity problem
This is the centerpiece of the Instagram design and the most common area where candidates get stuck. The question is: when user A posts a photo and they have N followers, how do you make sure all N followers see it in their feed, quickly, without melting the infrastructure?
Three strategies, worst to best for the extremes:
Pure fan-out on read — assemble the feed at query time by merging followee posts (avoid for users following many active accounts)
On each feed load, query the Post Store for recent posts from all of the user's followees, merge and sort them, and return the top 20.
Worked example — a user follows 500 accounts, each posting ~3/day:
per feed load: 500 DB reads (one per followee's recent posts)
+ merge sort of up to 1,500 posts
at scale: 50K feed loads/sec × 500 followees = 25M DB reads/sec
- Pro: zero write amplification — a post write is just one DB insert, no fan-out. Following / unfollowing is trivially consistent: the next feed load automatically reflects the change.
- Con: read is O(followees) — each feed load triggers hundreds of DB reads. At 50K feed loads/sec each requiring 500 reads, this is 25M reads/sec against your post DB — crushing. Latency is also terrible: merging 500 lists in real time adds hundreds of milliseconds.
- Verdict: acceptable only for extremely low-follower-count systems. Avoid at Instagram scale — the read amplification is fatal.
Pure fan-out on write — push to every follower's feed cache at post time (breaks for celebrities)
When a user posts, look up all their followers and write the post_id into each follower's FeedCache in Redis.
Worked example — Selena Gomez (400M followers) posts a photo:
fan-out writes: 400M ZADD operations on 400M different Redis keys
time to complete at 1M writes/sec: 400 seconds
For a celebrity, this is both impossibly slow and a thundering-herd write storm. Even at 1M cache writes/sec, fan-out for a single post takes minutes. While the fan-out is running, early followers see the post; late followers don't yet — a massive inconsistency window.
- Pro: reads are O(1) — a feed load just reads the pre-built sorted set. Fast.
- Con: write amplification is O(followers). For celebrities with 400M followers, pure fan-out on write creates an intolerable inconsistency window — early followers see the post while the last follower may wait minutes — and a write-amplification spike that overwhelms the cache write tier.
- Verdict: works perfectly for normal users (300–500 followers). Fails for celebrities due to inconsistency window and write spike. Don't use it alone.
Hybrid — fan-out on write for normal users, fan-out on read for celebrities, merged at query time (recommended)
Define a celebrity threshold — e.g. 1M followers. Users below the threshold are "normal" and get fan-out on write. Users above the threshold are "celebrities" — their posts are not pushed into follower feed caches. Instead, when a follower loads their feed, the Feed Service merges celebrity posts at read time.
How the hybrid read works:
At read time the Feed Service executes four steps:
- Read the caller's followee list from the Follow Store.
- Filter for accounts with
follower_count > thresholdusing a cached celebrity flag stored in user metadata (or a per-user celebrity-followee list cached in Redis) — avoiding a DB scan on every feed load. - Fetch recent posts from each celebrity followee (small list: ~5–10 accounts on average).
- Merge with the precomputed FeedCache (normal-user posts) and sort by
created_at.
flowchart LR
FeedSvc["Feed Service"] -->|"1. read followee list"| FollowStore[("Follow Store")]
FeedSvc -->|"2. filter celebrity followees\n(cached celebrity flag)"| CelebFlag[("User Metadata\n(celebrity flag cache)")]
FeedSvc -->|"3. fetch recent posts\nper celebrity"| CelebDB[("Post Store\n(celebrity posts)")]
FeedSvc -->|"4a. read precomputed\nfeed (post_ids)"| FCache[("FeedCache Redis\n(normal-user posts only)")]
FeedSvc -->|"4b. in-memory merge + sort"| Merge["Merged Feed\n(normal + celebrity)"]
Merge -->|"unified feed"| FeedSvc
The key insight: the average user follows only a handful of celebrities (say 5–10), while they might follow hundreds of normal users. Merging 5–10 celebrity post lists at read time is cheap — it's O(celebrities × recent_posts_per_celebrity), which is trivially small. The precomputed fan-out cache handles the long tail of normal users; only the small celebrity list requires a read-time merge.
The threshold in practice. Instagram likely uses a dynamic threshold based on follower count (e.g. > 1M), and the classification can change as users grow. A new celebrity account crosses the threshold; their historical posts are not backfilled into feed caches (they'd be picked up by the read-merge path).
Fan-out implementation. For normal users, fan-out workers execute the following per post:
1. Load the poster's follower list (from the reverse follow index)
2. For each follower_id (in a Lua script or MULTI/EXEC for atomicity):
a. ZADD feed:{follower_id} <created_at_score> <post_id>
b. ZREMRANGEBYRANK feed:{follower_id} 0 -(MAX_FEED+1) -- trim to cap
3. Set TTL on the feed key to ~30 days (prune inactive users)
The ZADD + ZREMRANGEBYRANK pair should be executed atomically via a Lua script (EVAL) or a Redis MULTI/EXEC transaction to prevent another writer from observing the set in a temporarily over-sized state between the two commands.
Fan-out workers are embarrassingly parallel — shard the follower list and process in parallel across workers. Each worker is idempotent: a ZADD with the same member and score is a no-op, so re-delivery from an at-least-once queue is safe.
What happens on follow / unfollow. A new follow does not immediately backfill the new followee's posts into the follower's cache (that would be expensive for a prolific poster). Instead, the Feed Service detects the new follow and either triggers a bounded backfill (the last N posts) or simply relies on the next feed load to merge those posts from the read path. Unfollow is handled lazily: the Feed Service filters out posts from unfollowed users at read time using a "following" membership check (or the feed cache is rebuilt on the next load). Neither operation should be synchronous on the follow/unfollow HTTP request.
- Pro: reads are O(1) for most users (cache hit); celebrity posts are merged cheaply (small N); write amplification is bounded for normal users; celebrities don't create write storms.
- Con: complexity — two code paths (push vs pull) to maintain; the threshold logic; fan-out lag means posts appear with a short delay in follower caches (seconds to tens of seconds under load); the read merge adds a small number of extra DB reads per feed load.
- Verdict: the standard production design for large social networks with power-law follower distributions. Fan-out on write for normal users, pull-and-merge for celebrities. This is what Facebook, Twitter/X, and Instagram use in various forms.
Warning
The single most common mistake in this question is choosing only one strategy — either pure fan-out on write ("push to all followers at post time") or pure fan-out on read ("query all followees at read time"). Neither works at Instagram scale across the full follower distribution. The correct answer is the hybrid, and you must name the celebrity problem and explain how the merge resolves it.
3. Follow graph, feed cache consistency, and likes at scale
The follow graph. Instagram follows are directed: A follows B does not imply B follows A. Store the follow edge in both directions:
- Forward index
(follower_id → [followee_id, ...])— answers "who am I following?" — used to look up celebrity ids when building the read-merge list. - Reverse index
(followee_id → [follower_id, ...])— answers "who follows me?" — used by fan-out workers.
Partition by follower_id for the forward index and by followee_id for the reverse index, so both lookups are single-partition reads. For celebrities, the reverse index partition (their follower list) can have hundreds of millions of entries — it must be sharded across multiple nodes.
Celebrity reverse-index sharding worked example. Suppose a celebrity has 400M followers and a single partition node can hold ~20M rows comfortably. Assign 20 shards (shard_id = follower_id % 20). Fan-out workers each claim one shard and operate in parallel — 400M followers ÷ 20 shards = 20M followers/worker; at 100K cache writes/sec per worker that's ~200 seconds total, which is still slow. For true celebrities we skip fan-out entirely (the celebrity threshold) — this example shows why the threshold is necessary even after sharding.
Denormalizing follower_count and following_count onto the User row makes profile page renders fast (no COUNT(*) against the follow table). These counters are updated asynchronously — eventual consistency is fine; you don't need exact counts within a millisecond of a follow event.
Feed cache consistency edge cases. The FeedCache is a derived structure. Several events can make it stale:
- Post deleted. The
post_idmay still be in follower FeedCaches. Handle at read time: multi-get metadata for the post ids in the cache; filter out any that come back as deleted/missing. Do not scan all FeedCaches to purge a deleted post_id — that's O(followers) of writes per delete. - Account deactivated. Similar to post delete — filter at read time.
- New follow. Backfill the last N posts from the new followee — bound this to ~20 posts to prevent a prolific account's full history flooding the cache on every new follow. Handle on the follow event (async) or lazily on first feed load.
- Unfollow. Lazy: don't scrub the cache immediately. The real fix is the Feed Service's read-time following-status filter — any post from an unfollowed user is dropped before being returned to the client. For bulk deletions or long-term hygiene, a background cache-cleanup job can rebuild or prune the cache asynchronously. Do not rely on a 30-day cache TTL as the unfollow consistency mechanism.
Likes at scale — the hot partition / hot key problem. A viral post gets thousands of likes per second. A naive implementation that does UPDATE posts SET like_count = like_count + 1 on every like creates a hot key — one row receiving 10K concurrent increment operations, which serialize and become a throughput bottleneck.
The pattern that solves this (same as the reference file's live-session count, same as Twitter likes):
- On each like, atomically insert the
(post_id, user_id)row (idempotent — prevents double-like). Then increment a Redis counterlikes:{post_id}usingINCR— Redis can handle hundreds of thousands of increments/sec against a single key. - A background job periodically (e.g. every 10–30 seconds) flushes the delta (not the absolute value). A robust version first atomically moves the counter into a pending flush record (for example
RENAME likes:{post_id} likes_flush:{post_id}:{flush_id}or append the delta to a durable stream), then appliesUPDATE posts SET like_count = like_count + <delta>with an idempotentflush_id, and finally marks that flush complete. This avoids the classicGETDELcrash window where the flusher deletes the Redis counter and dies before the DB update. Using the delta avoids overwriting the DB with a stale absolute value. If Redis crashes or evicts the key before a delta is captured, those in-flight likes are undercounted — an acceptable eventual consistency trade-off for approximate displayed counts. - Reads serve
like_countfrom the Redis buffer (the most current value) for hot posts, falling back to the DB for cold ones.
This is eventually consistent — the count displayed may lag by seconds. That is acceptable. No user can tell whether a post shows 14,032 or 14,039 likes.
Caution
Do not issue a UPDATE posts SET like_count = like_count + 1 on the posts table per like for popular posts. At 10K likes/sec on a viral post, this serializes 10K writes against one row, creating a hot key that stalls the DB. Buffer like counts in Redis and flush asynchronously to keep DB write rate bounded.
Comment counts follow the same pattern. Comment text goes directly to the comments table (lower volume, tolerable to write per-comment), but comment_count on the post row is buffered in Redis.
FeedCache cold rebuild and single-flight. When a user's FeedCache is missing (first visit, expired key, Redis node replaced), the Feed Service must rebuild it from the follow graph + post store. If N concurrent feed requests arrive simultaneously for the same cold user, N parallel rebuild queries will hammer the DB. Use a single-flight / mutex pattern: the first request acquires a short-lived Redis lock on rebuild:{user_id}; subsequent concurrent requests either wait briefly or serve a degraded empty feed and let the first request populate the cache. This avoids the thundering-herd of N identical expensive rebuilds.
Tradeoffs & bottlenecks
- Fan-out on write vs on read (the feed). Push post ids to follower caches at write time (O(1) reads, O(followers) write amplification, fails for celebrities) vs pull at read time (O(followees) per read load, trivial writes). The production answer is the hybrid with a celebrity threshold.
- Presigned upload vs server-side proxying. Presigned URLs keep API servers out of the media data path (critical at PB/day scale). The tradeoff is that the API loses visibility into the upload progress and cannot rate-limit or inspect bytes mid-upload (handle with client-side validation and post-upload virus scanning).
- Synchronous vs async transcoding. Async transcoding (via queue) decouples upload latency from processing time and scales independently. The tradeoff is a
processingstate during which the post is not yet renderable — acceptable. - Exact vs approximate like counts. Buffering in Redis and flushing asynchronously gives eventual consistency (seconds of lag) in exchange for eliminating the DB hot-key problem. Exact real-time counts would require serialized DB writes — untenable at viral scale.
- Eager vs lazy feed cache invalidation. When a post is deleted or a user is unfollowed, invalidating the FeedCache eagerly (remove the post_id from all follower caches) costs O(followers) writes. Lazy invalidation (filter at read time) costs a tiny read-time check. Lazy wins at scale.
- CDN cache invalidation. Immutable CDN URLs (content-addressed) avoid almost all invalidation. The rare forced invalidation (DMCA, account deletion) is handled by CDN purge API — infrequent and acceptable.
- Follow graph hot partitions. A celebrity's reverse-index partition (their follower list) is enormous. Shard it; range-partition their follower list across multiple nodes so no single partition hosts 400M rows.
Extensions if asked
If the interviewer wants to go beyond the core system, here are the natural extensions:
- Stories. A 24-hour ephemeral media format. Same upload pipeline (presigned URL → blob store → CDN), but feed placement is a horizontal carousel rather than main feed. Expiry is handled by TTL on the post row + a background job that removes expired story ids from feed structures.
- Explore / Reels recommendation. Ranking posts for users who don't follow the poster. This is a full recommendation system (user embeddings, content embeddings, engagement signals, FAISS or similar vector search) — mention it but do not design it in this interview.
- Direct Messages. A conversation graph and a message store (similar to WhatsApp design). Separate from the social feed entirely.
- Push notifications. A notification fanout service triggered by fan-out workers (someone you follow posted) — standard async pub/sub; use APNS/FCM for mobile delivery.
- Post search / hashtags. Inverted index over caption text (Elasticsearch-style); hashtag pages are a filtered post feed. Different read and write patterns from the home feed.
What interviewers look for & common mistakes
What interviewers usually reward:
- Naming the presigned URL pattern for media upload and explaining why API servers must not proxy bytes at scale. This distinguishes candidates who have thought about media systems from those who haven't.
- Reaching the hybrid fan-out strategy — not just naming fan-out on write but specifically identifying the celebrity problem and explaining the read-merge path for high-follower users. The threshold and how the merge works at read time is the heart of the answer.
- Treating the FeedCache as a derived structure — knowing it can be rebuilt from the follow graph and post store if lost, and that the post store is the source of truth.
- The media processing pipeline — async via a queue, decoupled from the upload response, producing multiple variants for adaptive bitrate streaming.
- The like counter hot-key pattern — Redis buffer + async DB flush, and understanding why a direct DB increment serializes under viral load.
- Eventual consistency as a deliberate choice — accepting a few seconds of lag for feed freshness, like counts, and comment counts in exchange for scalability.
- Cursor-based pagination for the feed (not offset-based, which produces duplicates when new posts are inserted during scrolling).
Before you finish, do a quick mistake check:
- Did you use a presigned URL for upload, and explain that API servers should never proxy media bytes?
- Did you avoid pure fan-out on write, name the celebrity problem, and describe the hybrid merge strategy with a threshold?
- Did you treat the FeedCache as a derived structure — never the source of truth — that can be rebuilt from the follow graph?
- Did you put transcoding behind an async queue and return 202 from the publish endpoint?
- Did you buffer like counts in Redis and flush to DB, rather than doing a per-like DB update?
- Did you store only a CDN URL pointer in the post row, never the media bytes?
- Did you use cursor-based (not offset-based) pagination for the feed?
- Did you mention CDN for media delivery and explain why the CDN layer is non-optional at this scale?
Practice this live
Run this exact question with our AI voice interviewer and get feedback.
Start the interview →