Build a photo-first social network: users post photos and short videos to their followers, browse a home feed, and share stories that vanish after 24 hours. The feed is the Design a Newsfeed problem wearing a media body - the genuinely new organ is content with an expiration date.
Instagram is mostly a composition of systems this practice set already covers. The follow graph, feed fan-out, celebrity problem, likes, and comments are Design a Newsfeed, imported wholesale. Video processing is Design YouTube, imported slim. Rebuilding those here would waste the interview. What is genuinely new - and where this page spends its depth - is two things. First, the image pipeline: photos have their own processing concerns (resolution ladders for different screens, EXIF stripping for privacy, placeholder generation for perceived speed) that are lighter than video but not trivial. Second, and most distinctive: ephemeral content. Stories introduce a problem no other design in this set has - content with a hard expiration date, where "gone after 24 hours" is a promise the whole architecture has to keep, from the database to the CDN. Saying this out loud in an interview is a strength, not a dodge: "the feed here is the standard newsfeed design - let me focus on what is different about Instagram" shows you recognize composed systems and know where the novel work lives.
The expiry promise hides two different requirements that are worth separating, because they get opposite treatments. Logical expiry is the correctness promise: at hour 24, no user can see the story anywhere. This must be sharp and instant - and the only way to make it sharp is to enforce it at read time. Every query that could surface a story filters on expires_at > now. That check costs nothing, works the moment the clock passes, and does not depend on any cleanup job having run. If all the deletion machinery in the system crashed for a day, stories would still vanish on time, because visibility never depended on deletion. Physical expiry is the economics: the story's rows and media bytes should actually be deleted, reclaiming storage. This can be lazy - a sweeper that runs hourly, batch-deleting expired records and letting a storage lifecycle rule remove the bytes. Nobody can observe whether cleanup ran at hour 24 or hour 30, so it is allowed to be cheap and batched. The subtle third surface is caches and the CDN: a copy cached at hour 23 must not be servable at hour 25. The elegant fix is to make expiry a property of the URL itself - story media URLs are signed with an expiration equal to the story's expires_at, so the CDN mechanically refuses them the moment the story dies, with no purge required. One sentence to carry out of this: correctness at read time, economics in batch. Design URL Shortener made the same split for expired links - the 404 is instant, the cleanup is scheduled.
The numbers split Instagram's content into two species with opposite storage stories, and the contrast is the most useful thing on this page. Posts accumulate. ~300TB/day of media, forever - a growth curve that reaches ~110PB/year. Everything Design YouTube concluded about permanent media applies at photo scale: bytes go to object storage behind a CDN, popularity skew makes edge caching absorb most reads, old cold media tiers down to cheaper storage classes, and the database holds only metadata and pointers (the Design Dropbox bytes-versus-metadata split, third appearance). Stories evaporate. ~400TB/day flows in, but the 24-hour lifetime means steady-state storage is one day's window - a constant ~400TB no matter how many years the product runs. Ephemerality is not just a product feature; it is a storage architecture: the entire stories system, including its 15B view events a day, is self-cleaning by construction. Every stories row and blob carries its own death date, which is why the data model leans on TTLs, expiry-day indexes (the Design URL Shortener cleanup pattern), and storage lifecycle rules rather than ever-growing tables. The graph, feed, likes, and comments are the Design a Newsfeed stack verbatim - NoSQL feed pointers, hybrid celebrity fan-out, buffered counters - at roughly 5x the volume, which changes the thresholds but not the design. We do not re-derive any of it here. So the database story is: import the Newsfeed stack for the social skeleton, add object storage + CDN for the media body (YouTube economics), and give stories their own TTL-native corner where everything expires. Three regimes, chosen by content lifetime.
POST - permanent content (the Newsfeed write path + media)
[Client] --presigned upload--> [Object Storage: original]
|
v
[Image Processor]
strip EXIF | resolution ladder
1080 / 720 / 320 | blurhash
|
v
[Object Storage: renditions] --> [CDN]
[Client] --> [Post Service] --> [Posts DB] --> [Fan-out]
(from here, exactly Design a Newsfeed:
pointers pushed, celebrities pulled)
STORY - ephemeral content (the new organ)
[Client] --> [Story Service] --> [Stories DB: expires_at = now + 24h]
| (expiry-day index for the sweeper)
+--> [author's active-story list (Redis, TTL)]
media URLs are SIGNED TO DIE AT expires_at -> CDN
cannot serve a dead story even from cache
STORY TRAY - read path (pull, not push)
[Client] --> [Story Service] --> following list
--> per-author active lists (shared, cached hard)
--> filter expires_at > now <- THE expiry guarantee
CLEANUP - lazy, invisible, interruptible
[Sweeper (hourly)] --> expiry-day index --> batch-delete rows
[Storage lifecycle rule] --> deletes story bytes after expiryThe feed and the story tray ask the same fan-out question - how does content from accounts you follow reach you? - and get opposite answers. Working out why is one of the best exercises in this set. The feed (per Design a Newsfeed) is push: when someone posts, pointers are written into each follower's feed ahead of time, because a feed is a per-user merged timeline read constantly - precomputing it makes millions of reads cheap. The tray is pull. When you open the app, the Story Service takes your following list and checks each account's active-story list - a small per-AUTHOR structure, not a per-viewer one. Two properties flip the answer. First, the read shape: the tray shows authors, not a merged timeline, and every one of an author's followers reads the exact same active list - one cached object serves millions of viewers, the same shared-read economics as the celebrity pull path in Newsfeed. Second, the lifetime: pushing story pointers into millions of trays would write fan-out amplification for content that self-destructs tomorrow - maximum write cost for minimum useful life. The transferable lesson: push versus pull is not a property of your app - it is a property of each content type, decided by read shape and content lifetime. One product, two fan-out answers, both right.
Compare the two content types at ten years of operation. Posts: ~300TB/day, accumulating. Year one ends near 110PB; year ten ends near 1.1EB. Every byte ever posted is still there, still replicated, still costing money - which is why permanent media needs the whole YouTube toolkit: popularity tiering, cold storage classes, CDN economics. Stories: ~400TB/day of inflow, but steady-state storage is capped at one day's window - roughly 400TB in year one, and still roughly 400TB in year ten. The system's busiest write surface (200M stories plus 15B view events a day) contributes zero long-term growth. Deletion with a deadline turned a compounding cost into a constant. This is the same insight as Design URL Shortener's 1-year link expiry - TTLs cap the table - taken to its extreme: there, expiry was housekeeping; here, it is the product itself, and the architecture (TTL attributes, expiry-day indexes, lifecycle rules, self-deleting view rows) is built so that everything about a story carries its own death date from the moment it is written. Nothing needs to remember to clean up, because nothing was ever going to live. One honest caveat: Instagram's real product quietly archives your expired stories for you (visible only to you). That flips the storage math back to accumulating - a reminder that a single product checkbox ("archive my stories") can undo an architecture's neatest property, and worth mentioning in an interview as the kind of product-engineering negotiation that actually happens.
Schemas (Posts / Feed / Graph / Comments: unchanged from Design a
Newsfeed - shown here only where Instagram differs)
- Posts (Newsfeed schema + media)
...all Newsfeed fields...
media JSONB:
[{ "type": "image",
"renditions": { "1080": "url", "720": "url", "320": "url" },
"blurhash": "LEHV6nWB2yk8pyo0adR*.7kCMdnj" }]
- Stories (NoSQL, e.g. DynamoDB)
PK: author_id, SK: story_id (time-ordered)
Attributes: media_refs, created_at, expires_at, view_count
TTL attribute: expires_at + grace -- rows self-delete
GSI (the sweeper's index): PK: expiry_day ("2026-07-06"), SK: story_id
-- "today's dead stories" as one bucket read: the Design URL Shortener
-- expiration-index pattern, verbatim
-- EVERY read of this table filters expires_at > now; visibility never
-- waits for deletion
- Story Views (NoSQL)
PK: story_id, SK: viewer_id -- one row per viewer: idempotent
Attributes: viewed_at
TTL: story expires_at + grace -- 15B rows/day, all self-deleting
-- readable only via the author-only viewers endpoint
Object storage layout
/media/posts/{post_id}/{1080,720,320}.webp -- permanent, tiered by age
/media/stories/{story_id}/* -- lifecycle rule: delete
-- after expiry
-- story URLs are presigned with expiry = the story's expires_at:
-- the CDN refuses them at hour 24 automatically, no purge needed
Redis keys
- Active stories per author (the tray's building block)
"active:{author_id}" -> [ {story_id, expires_at}, ... ]
TTL: latest expires_at -- rebuildable from Stories DB on miss
- Tray cache
"tray:{user_id}" -> rendered tray, TTL ~60s
- Story view counters (Newsfeed buffered-counter pattern)
"sviews:{story_id}" -> pending increments, flushed every few seconds
- Seen markers (orders the tray)
"seen:{user_id}:{author_id}" -> last seen story_id, TTL 48h{
"caption": "Golden hour",
"media_refs": ["upload_8f3e2d1c"]
}{
"post_id": "p_9a1b44",
"media": [
{
"type": "image",
"renditions": {
"1080": "https://cdn.example.com/media/posts/p_9a1b44/1080.webp",
"720": "https://cdn.example.com/media/posts/p_9a1b44/720.webp",
"320": "https://cdn.example.com/media/posts/p_9a1b44/320.webp"
},
"blurhash": "LEHV6nWB2yk8pyo0adR*.7kCMdnj"
}
],
"created_at": "2026-07-06T18:04:11Z"
}
// The original was uploaded via presigned URL and processed first (EXIF
// stripped, ladder rendered). The blurhash is ~30 characters that decode
// into a blurred preview - feeds render it instantly while real pixels
// load. Fan-out to followers proceeds per Design a Newsfeed.{
"media_ref": "upload_7a2b8f4e"
}{
"story_id": "s_4c8e6f2a",
"expires_at": "2026-07-07T18:04:11Z",
"media_url": "https://cdn.example.com/media/stories/s_4c8e6f2a/1080.webp?sig=...&exp=1751911451"
}
// Note exp in the URL equals the story's expires_at: every copy of this
// URL - including whatever the CDN cached - stops working at the same
// moment the story logically dies. Expiry travels WITH the content.{
"tray": [
{ "author": { "user_id": "u_456", "name": "dana",
"avatar": "https://cdn.example.com/a/u_456.webp" },
"active_stories": 3, "all_seen": false },
{ "author": { "user_id": "u_bigband", "name": "The Big Band",
"avatar": "https://cdn.example.com/a/u_bb.webp" },
"active_stories": 1, "all_seen": true }
]
}
// Built by PULL: your following list x per-author active lists (one
// small shared cache object per author, read by all their followers).
// Unseen authors sort first. Every entry already passed the
// expires_at > now filter - the tray cannot show a dead story.{ "story_id": "s_4c8e6f2a" }// viewer's beacon response:
{ "ok": true }
// GET /v1/stories/s_4c8e6f2a/viewers (author only):
{
"view_count": 18240,
"viewers": [
{ "user_id": "u_789", "name": "sam",
"viewed_at": "2026-07-06T19:12:40Z" }
],
"next_cursor": "2026-07-06T19:12:40Z#u_789",
"has_more": true
}
// One row per (story, viewer): rewatching never double-counts. The
// count is approximate (buffered counter, the Newsfeed pattern); the
// list is exact, author-only, and every row TTLs away with the story.// 404 - story expired, deleted, or never existed: identical on purpose
{
"error": "not_found",
"message": "Story not found"
}
// An expired story is indistinguishable from one that never existed -
// "it was there an hour ago" leaks nothing now.
// 403 - viewer list is the author's alone
{
"error": "forbidden",
"message": "Only the story's author can view this"
}Blurhash: a tiny string (~30 characters) that encodes a heavily blurred version of an image, computed once at processing time and shipped inside the feed JSON itself. The client decodes it locally and paints the blur before any image byte arrives, so the feed never shows gray boxes. It costs almost nothing and is most of why media apps feel fast on bad connections - perceived speed is a design surface of its own. EXIF stripping: photos from phones carry embedded metadata - camera model, timestamp, and crucially GPS coordinates of where the photo was taken. Serving originals would hand every follower the location of your home, workplace, and kids' school. The processor strips all of it before any rendition is stored. This is a one-line step that is a genuine privacy requirement, and forgetting it is a classic real-world breach. Lifecycle rule: a policy attached to a storage bucket or prefix - "delete objects here N hours after creation" - executed by the storage system itself. Story bytes get cleaned up by the infrastructure, not by our code: no sweeper we write can forget, crash, or fall behind in a way that leaks storage forever. Delegating deletion to the platform is the physical-expiry counterpart of enforcing visibility at read time.