loading
loading
A seed-funded photo feed app just raised its Series A. Your job is to design a system that works at 2,000 rps today, survives a 5x growth to 10,000 rps after the press cycle, and is architecturally ready for the 40,000 rps a Series B brings -- all within per-stage infrastructure budgets. 90% of traffic is feed reads. The personalization requirement changes everything about what you can cache.
Steady traffic
2,000 rps
Spike multiplier
3×
Budget
$8,000/mo
Read ratio
90:10
Load profile
Day 1 post-launch: 2k rps, the naive design holds. Two months later the TechCrunch coverage lands and you wake up to 10k rps on a Tuesday morning. Six months after that, Series B closes and the growth team promises 40k rps by the next quarter. Each stage is a different design problem -- you cannot just "add more servers." The hard problem is the personalized feed: every user sees a different ranking of posts from people they follow. At 2k rps you query Postgres on every request. At 10k rps that query saturates the DB. At 40k rps you need to change the write architecture entirely, not just the read path.
Functional
cursor-based paginationNon-functional
region-A outage with less than 5% error rate (Stage 2+)The endpoints your system must implement. The hot path is the one the SLO is measured on.
Query params
| field | type | notes |
|---|---|---|
| limit? | integer | Number of posts per page (default: 20, max: 50)e.g. 20 |
| cursor? | string | Opaque pagination cursor from previous response (encodes last seen timestamp + post_id)e.g. eyJ0IjoiMjAyNi0wNi0xNFQxMjoiLCJpZCI6IjEyMyJ9 |
Response body
| field | type | notes |
|---|---|---|
| posts | array | List of post objects in reverse-chronological order |
| next_cursor | string | Cursor for the next page; null means end of feed |
| stale | boolean | true if the feed was served from cache and may be slightly stale (graceful degradation signal) |
Status codes
THE HOT PATH -- 36,000 rps at Stage 3. This endpoint cannot touch Postgres on every request at that scale. At Stage 1 query Postgres directly. At Stage 2 serve from Redis-cached feed index. At Stage 3 feed index is pre-computed via fan-out on write, served from Redis. The stale field tells the client whether to show a "feed may be updating" indicator.
Path params
| field | type | notes |
|---|---|---|
| post_id | string | Post UUID |
Response body
| field | type | notes |
|---|---|---|
| post_id | string | |
| user_id | string | |
| username | string | |
| image_url | string | Pre-signed CDN URL for the full photo |
| thumbnail_url | string | Pre-signed CDN URL for the thumbnail (shown in feed) |
| caption | string | |
| like_count | integer | Approximate -- uses Redis counter, eventually consistent |
| created_at | string |
Status codes
Individual post metadata is SHARED across all viewers -- unlike the feed. This is the ideal CDN cache target. Cache-Control max-age=60 for like_count (approximate is fine), max-age=3600 for immutable fields (image_url, caption). At Stage 3, post:{id} in Redis handles recently-posted posts before CDN has them cached at the edge.
Request body
| field | type | notes |
|---|---|---|
| image_key | string | Object storage key for the already-uploaded image |
| caption? | string | Optional caption (max 2200 chars) |
Response body
| field | type | notes |
|---|---|---|
| post_id | string | |
| created_at | string | |
| fan_out_status | string | queued | complete -- queued means feed updates are being applied asynchronously |
Status codes
Write path (10% of traffic = 200-4,000 rps depending on stage). The post is immediately visible to the poster. Fan-out to followers' feeds is synchronous at Stage 1 (small follower counts), async at Stage 2+ (may take up to 5s). Celebrity accounts (>100k followers) ALWAYS use pull-on-read at Stage 3 -- no fan-out on write for them.
Query params
| field | type | notes |
|---|---|---|
| content_type | string | MIME type (image/jpeg or image/png) |
Response body
| field | type | notes |
|---|---|---|
| upload_url | string | Pre-signed PUT URL (expires in 5 minutes) |
| image_key | string | The key to pass to POST /posts after upload |
Status codes
Photos upload directly to object storage -- the feed-api never touches binary data. Pre-signed URLs expire in 5 minutes. After upload the client calls POST /posts with the image_key to create the post. Thumbnails are generated asynchronously via a background job.
The core content entity. At Stage 1 the feed query does a JOIN between follows and posts on every request. At Stage 3 this table is the write target -- the read path is Redis, not Postgres. The index on (user_id, created_at) is critical for both the JOIN and the fallback.
| column | type | constraints | notes |
|---|---|---|---|
| id | uuid | PK | DEFAULT gen_random_uuid() |
| user_id | uuid | IDX · FK→users.id | Index for get-my-own-posts and fan-out queries |
| image_key | varchar(512) | — | Object storage key -- NOT a full URL (pre-sign at read time) |
| caption? | text | — | |
| like_count | bigint | — | Approximate counter; source of truth is Redis, DB is eventual |
| created_at | timestamptz | IDX | Index is critical for time-ordered feed queries |
| deleted_at? | timestamptz | — | Soft delete -- filter with WHERE deleted_at IS NULL |
capacity · 200 post writes/rps x 86,400s = ~17M posts/day. Each row is ~1KB. 17GB/day written -- after 90 days 1.5TB. Partition by month after 3 months or archive to cold object storage. The feed query at Stage 3 does NOT touch this table on the hot path -- only writes and cache misses.
Social graph edges. Every feed read at Stage 1 does a SELECT posts JOIN follows WHERE follower_id = $1 ORDER BY created_at DESC LIMIT 20. At Stage 3 this table is ONLY used by the fan-out worker to enumerate a poster's followers. The read path is Redis, not this table.
| column | type | constraints | notes |
|---|---|---|---|
| follower_id | uuid | IDX · FK→users.id | Part of compound primary key |
| followee_id | uuid | IDX · FK→users.id | Part of compound primary key |
| created_at | timestamptz | — | When the follow happened |
capacity · Average user follows 200 accounts. 10M users = 2B rows = ~100GB. The followee_id index is the critical path for fan-out: SELECT follower_id FROM user_follows WHERE followee_id = $1 scans this index to find all accounts to notify. Add this index at Stage 1 on a small table -- not at Stage 2 on 2B rows (would require a multi-hour lock).
NOT a Postgres table -- this is the Redis representation of each user's pre-computed feed. Stored as a ZSET where score = unix timestamp and member = post_id. This is the primary feed store at Stage 2+. Postgres is the fallback for cache misses, not the primary read path.
| column | type | constraints | notes |
|---|---|---|---|
| member | string | PK | post_id (UUID as string) -- each member is a unique post |
| score | float64 | — | UNIX timestamp of the post -- ZREVRANGEBYSCORE returns chronological feed |
capacity · Each user's feed ZSET holds up to 100 post IDs. Each post_id is a 36-char UUID = 36 bytes + 8-byte score = ~44 bytes. 100 entries/user = ~4.4KB/user. 10M DAU x 4.4KB = 44GB -- fits in a Redis r6g.2xlarge cluster (64GB usable). Key pattern: feed:{user_id}.
Key patterns, TTLs, and commands. Your design must justify the hotness-critical keys.
Pre-computed personalized feed index for each active user. The score is the post's created_at unix timestamp; members are post_ids. Served to GET /feed requests at Stage 2+ without touching Postgres on the hot path.
ZREVRANGE feed:{user_id} 0 19 WITHSCORES # first page: newest 20 posts
ZREVRANGEBYSCORE feed:{user_id} {cursor} -inf LIMIT 0 20 # paginated
ZADD feed:{user_id} {created_at_unix} {post_id} # fan-out write path
ZREMRANGEBYRANK feed:{user_id} 0 -101 # trim to 100 entries on each ZADD
EXISTS feed:{user_id} # cold-start check: miss -> rebuild from Postgres
rationale · At Stage 2 (9,000 read rps), Postgres cannot serve 9,000 social graph queries per second -- each is a multi-table JOIN. Redis ZSET serves each feed read at O(log N) in < 1ms. Cold starts fall back to Postgres and backfill Redis. The 1h TTL plus LRU eviction means inactive users' feeds are evicted, keeping the working set to hot users only.
Metadata cache for individual posts. Shared across all viewers -- unlike the feed, post metadata is the same for everyone. Serves recently-posted posts before CDN has them cached at the edge.
HGETALL post:{post_id} # read all fields at once
HINCRBY post:{post_id} like_count 1 # optimistic like counter
HSET post:{post_id} like_count {n} # periodic sync from DB
rationale · When a user's feed page loads it renders 20 post cards. Without this cache at Stage 3: 36,000 feed reads/rps / 20 posts = 1,800 unique post lookups/rps hitting Postgres. Redis HGETALL is 0.3ms and shared -- 1,000 concurrent users reading the same viral post all hit the same cached HASH.
Flag marking a user as a "celebrity" -- follower count above the fan-out threshold (default: 100k followers). Posts from these users are served via pull-on-read rather than fan-out-on-write. Checked by POST /posts handler.
GET hot-follows:{user_id} # exists? use pull-on-read. missing? fan-out.
SET hot-follows:{user_id} 1 EX 86400 # set when follower count crosses threshold
DEL hot-follows:{user_id} # clear when user drops below threshold
rationale · The celebrity problem: a user with 5M followers who posts would generate 5M ZADD operations in Redis if using fan-out on write -- 50 seconds of fan-out worker time. Instead, celebrity posts are NOT fanned out. When building a user's feed, the feed builder fetches each celebrity's recent posts from a dedicated ZSET and merges them at read time. This is the hybrid fan-out strategy that makes Stage 3 possible.
Deduplication key for likes -- prevents a user liking the same post twice at high concurrency. Also used as the authoritative like state before the Postgres write is confirmed.
SET like:{post_id}:{user_id} 1 EX 172800 NX # NX = only if not liked yet
rationale · At high concurrency, two rapid like-taps can both pass a DB uniqueness check before either commits. Redis SET NX is atomic -- only one wins. The like_count in post:{post_id} is updated optimistically via HINCRBY; the Postgres like_count column is synced periodically via batch job.
Pre-computed numbers to anchor your justifications. Use these — the grader checks your claims against them.
traffic
1,800 rps
Stage 1 feed read throughput
= 2,000 x 90% reads
traffic
200 rps
Stage 1 write throughput
= 2,000 x 10% writes (posts + follows)
traffic
9,000 rps
Stage 2 feed read throughput
= 10,000 x 90% reads
traffic
36,000 rps
Stage 3 feed read throughput
= 40,000 x 90% reads -- this cannot be served from Postgres
latency
~5ms
Social graph query cost at Stage 1
= SELECT posts JOIN follows WHERE followee_id IN (...) -- fine at Stage 1, brutal at Stage 3
capacity
720 MB/s
Stage 3 raw data bandwidth
= 36,000 feed reads/s x 20KB payload -- CDN edge serves this, not origin
storage
~4KB
Redis feed cache per user
= Feed index: 100 post IDs x 40 bytes each -- very compact in ZSET
storage
~40GB
DAU-scale Redis footprint
= 10M DAU x 4KB feed index = 40GB -- Redis cluster is viable
capacity
~200 writes per post
Fan-out write amplification (typical user)
= Average follower count 200 -- fan-out on write inserts 200 Redis ZADD per post
capacity
5,000,000 writes per post
Fan-out for celebrity user
= Celebrity with 5M followers -- fan-out on write is NOT viable for celebrities
cost
~$1,200/mo
Stage 3 Redis cluster cost estimate
= 40GB + buffer -- r6g.2xlarge cluster ($600/node x 2 for HA)
Work through these phases in order before submitting. Each phase builds on the last.
Phase 1 -- Stage 1 Baseline: Make 2k rps Work
10 min · Design the simplest possible system that serves 2k rps within a $1,000/month bud…
Design the simplest possible system that serves 2k rps within a $1,000/month budget. At this scale, Postgres can serve all reads. The goal is a clean foundation -- not over-engineering for Stage 3 on Day 1.
At 1,800 read rps and a 5ms Postgres query: what is the DB utilization?
1,800 reads/s x 5ms/query = 9 concurrent queries on average. Postgres handles 500+ concurrent connections -- this is ~2% utilization. Fine. Stage 1 doesn't need Redis for the feed. One feed-api instance at maxRps=2,000 + social-db at maxRps=3,000 handles this comfortably within the $1k budget.
Where do the photos live, and what does a feed response actually contain?
Photos go to object storage (S3/GCS). The feed API returns JSON with post metadata + pre-signed thumbnail URLs. The API never serves binary data. At Stage 1, pre-signed URLs are generated on the fly per request (< 1ms). Photo thumbnails are pre-generated by a background job after upload.
What indexes must exist on posts and user_follows from Day 1?
posts: (user_id, created_at DESC) for get-posts-by-user. user_follows: (followee_id) for fan-out queries. Adding the followee_id index at Stage 2 on a 2B row table requires a multi-hour lock. Adding it at Stage 1 on a 10M row table takes seconds. The schema decisions made now are the ones you'll live with at Stage 3.
Deliverable
Canvas: feed-api (1 replica) + social-db + photo-storage. Verify 0% errors at 2k rps and total spend well under $1k/mo.
Phase 2 -- Stage 2 Cache Strategy: Handle 10k rps
15 min · At 9,000 feed reads/rps, social-db is saturated. You need a cache layer -- but w…
At 9,000 feed reads/rps, social-db is saturated. You need a cache layer -- but what do you cache? The answer isn't obvious because feeds are personalized. Figure out what's cacheable and what isn't before adding Redis.
Can you cache the feed response in a CDN or reverse proxy?
Feed pages are personalized (unique per user) -- a CDN keyed only on the URL (/feed) would serve user A's feed to user B. You'd need to key on the session cookie, which means effectively 0% CDN hit rate. CDN doesn't help for personalized feeds. CDN DOES help for individual post thumbnails and photos -- those are shared content.
What's the unit you CAN cache that makes sense for ALL users simultaneously?
The feed INDEX per user -- a list of post IDs in order, not the full post data. Post metadata (image URL, caption, like count) is shared across all viewers who see that post, so it CAN be cached in shared storage. Two-layer strategy: feed:{user_id} ZSET (personalized index) + post:{id} HASH (shared metadata). Feed read = 1 ZREVRANGE + N HGETALL, all in Redis, zero Postgres on the hot path.
What invalidates the feed cache when a new post is created?
On POST /posts: query user_follows WHERE followee_id = $poster to get all followers, then ZADD post_id to each follower's feed:{follower_id} ZSET. This is fan-out on write. At Stage 2 with avg 200 followers: 200 ZADD operations per post. At 200 writes/rps x 200 ZADD = 40,000 Redis writes/rps from fan-out -- still fine for Redis. The celebrity case (5M followers) is where this breaks at Stage 3.
Deliverable
Canvas: feed-api + Redis cluster + social-db (now mainly for writes and cold-start rebuilds). Verify 0% errors at 10k rps, within $3k/mo budget.
Common pitfall
Caching the full rendered feed JSON in Redis instead of just the index. Post metadata changes (like counts, deletions) would require invalidating every user's feed cache -- millions of Redis DEL operations per like event. Cache the feed INDEX (post IDs only), fetch fresh post metadata separately.
Phase 3 -- Stage 3 Fan-Out: Solving the Celebrity Problem
20 min · At Stage 3 (40k rps, 4k post writes/rps), naive fan-out on write breaks for high…
At Stage 3 (40k rps, 4k post writes/rps), naive fan-out on write breaks for high-follower accounts. Design the hybrid fan-out strategy that handles both regular users (under 100k followers) and celebrities (millions of followers) without grinding fan-out workers to a halt.
What happens to your fan-out workers when a celebrity (5M followers) posts?
Fan-out on write: enumerate 5M followers then ZADD to each feed. At 100k ZADD/s per Redis instance, this takes 50 seconds per celebrity post. During those 50 seconds, the feed staleness NFR (5s) is violated for all 5M followers. If 10 celebrities each post simultaneously, the fan-out queue backs up by 50M operations and never catches up.
What is the hybrid fan-out strategy?
Split by follower count at a threshold (e.g., 100k followers). NORMAL users (below threshold): fan-out on WRITE as before. CELEBRITY users (above threshold): pull on READ. When the feed builder constructs a feed for a user who follows celebrities, it fetches each celebrity's recent posts from their own ZSET and merges them with the pre-computed feed at read time. This adds 1-2 Redis ZREVRANGE calls per celebrity followed -- usually bounded to < 5 celebrities per user in practice.
How do you determine the safe celebrity threshold?
The threshold = fan-out worker throughput (ZADD/s) x staleness budget (seconds). If you can do 100k ZADD/s across all workers and the NFR is 5s: max fan-out = 100k x 5 = 500k followers. Set threshold at 500k. Lowering the threshold protects Redis from fan-out storms but increases the number of celebrities needing pull-on-read merging. It's a tuning knob -- size it to your worker capacity, not an arbitrary "famous person" number.
Deliverable
Canvas: async fan-out worker pool + celebrity bypass path in feed builder. Verify 0% errors at 40k rps and describe how a celebrity post is served to their followers within the 5s staleness budget.
Common pitfall
Setting the celebrity threshold too high (e.g., 1M followers). At 1M followers, fan-out still takes 10 seconds -- violating the 5s NFR. The threshold must be computed from your worker capacity math, not chosen intuitively.
Phase 4 -- Graceful Degradation and DB Availability
15 min · At Stage 3 the entire feed depends on Redis. A Redis cluster failure means ALL f…
At Stage 3 the entire feed depends on Redis. A Redis cluster failure means ALL feeds fail simultaneously. Design the degradation modes and the region-A outage plan BEFORE the outage happens.
If Redis cluster is unavailable, can you still serve a feed?
Yes -- fall back to the Stage 1 Postgres query. It's slow (5-10ms) and can't handle 36,000 rps, but during a Redis outage you rate-limit feed requests to what Postgres can absorb (~3,000 rps) and shed the rest with 503 + Retry-After. Users see a "feed updating" indicator (the stale flag) rather than a hard error. A partial feed is better than a 503 -- the API contract already includes the stale field.
What happens to the fan-out queue during a Redis outage?
Fan-out workers must be able to pause and replay. Use a durable message queue (SQS or Kafka) between POST /posts and the fan-out workers. During Redis outage: workers retry ZADD with exponential backoff, failed operations stay in the queue. When Redis recovers, the queued events replay and feed caches are rebuilt. Without a durable queue, posts written during the outage are silently missing from followers' feeds even after Redis recovers.
What is the plan when social-db primary fails?
social-db primary goes down: writes (POST /posts, follow/unfollow) fail with 503. Feed reads continue -- they hit Redis first, and the Redis cluster is cross-region. Reads that miss Redis fall back to the read replica in region-B. The < 5% error rate NFR covers the outage period: reads stay at ~0% errors, writes fail for the duration. This is correct prioritization -- in a social feed, reads are more valuable than writes during an incident.
Deliverable
State your degradation modes in the overall defense: Redis failure path, social-db primary failure path, and the durable queue that prevents silent data loss during either outage.
Phase 5 -- Cost Discipline Across All Three Stages
15 min · You have three budgets ($1k, $3k, $8k) and three very different traffic levels. …
You have three budgets ($1k, $3k, $8k) and three very different traffic levels. Design what to buy per stage, what to defer, and identify the biggest cost optimization at Stage 3.
At Stage 1 ($1k/mo), what is the most expensive premature optimization?
Adding Redis at Stage 1 costs $300-600/mo for almost no benefit -- Postgres serves 1,800 rps at 2% utilization. The Stage 2 Redis purchase deferred saves $600/mo for 2 months = $1,200 saved before it's needed. Building the fan-out worker at Stage 1 is a full engineering quarter on infrastructure that isn't needed for 6 months.
What must be designed right at Stage 1 that cannot be changed cheaply later?
The schema and indexes. Adding the (followee_id) index to a 2B-row table at Stage 2 requires a full table scan -- hours of locking in production. Adding it at Stage 1 on 10M rows takes seconds. The pagination cursor format (opaque cursor vs. page number) is a client contract -- changing it at Stage 2 breaks all existing mobile clients. The media upload flow (pre-signed URL to object storage) -- migrating binary data later means touching every stored photo.
At Stage 3 ($8k/mo), what is the largest cost lever?
CDN hit rate for photo thumbnails. At 36k reads/rps with 80KB average thumbnail: 36,000 x 80KB x $0.085/GB CDN egress = ~$9,000/mo IF all requests are cache misses. At 99% CDN hit rate: $90/mo egress. Every 1% improvement in CDN hit rate saves ~$90/mo. Use aggressive Cache-Control headers (max-age=31536000 for immutable photos) and set a thumbnail CDN cache hit rate alert as a cost KPI.
Deliverable
A per-stage budget breakdown in your defense: what you buy, what you defer, and the single largest cost optimization made. Must be within each budget.
2k rps, $1k budget, zero errors. No unnecessary components -- adding Redis at Stage 1 is a cost violation.
Full credit
0% errors at 2k rps, within $1k/mo, no premature components.
Partial
0% errors but over budget, or unnecessary complexity added.
Zero
Errors at Stage 1 -- the baseline is broken.
10k rps, $3k budget. The feed read path cannot touch Postgres on every request -- a caching strategy must be present and correct.
Full credit
0% errors at 10k rps, feed served from cache (no Postgres on hot path), within $3k.
Partial
Low errors but Postgres still the primary read path (will break at Stage 3).
Zero
More than 2% errors at 10k rps, or over budget.
40k rps, $8k budget. Naive fan-out on write breaks for celebrity accounts. The hybrid fan-out strategy must be designed or credibly described.
Full credit
0% errors at 40k rps, fan-out strategy addresses celebrity case, within budget.
Partial
0% errors at 40k rps but celebrity problem unaddressed (fan-out queue will storm).
Zero
Errors at 40k rps or massively over budget.
social-db primary goes down for 60s. Reads must continue via Redis plus read replica. Writes may fail gracefully during the window.
Full credit
Error rate under 5% during outage; feeds from Redis/replica; writes fail gracefully.
Partial
Error rate 5-15% with a credible recovery path described.
Zero
Complete feed failure during outage or error rate above 15%.
Each stage transition must be justified: what broke, what changed, why that change was necessary, and what it cost. Vague answers lose points.
Full credit
Each stage change argued with utilization numbers (e.g., "Postgres at 300% -> Redis drops it to 5% of queries"). Celebrity threshold computed from worker capacity math. Cost breakdown per stage.
Partial
Correct design choices but justifications lack numbers.
Zero
No per-stage reasoning -- just a final design with no explanation of the journey.
Each scenario fires automatically during your simulation run. Your design must survive all of them.
Stage 1 -- Seed (2k rps)
Stage 2 -- Series A (10k rps)
Stage 3 -- Series B (40k rps)
Region A DB outage (Stage 2+)
Ready to build it?
Best on desktop — the canvas needs room to breathe. Drafts autosave locally.