Instagram Stories Interview Questions | JiQuest

add

#

Instagram Stories

System design deep dive · HLD

Design Instagram Stories: ephemeral content, full high-level design.

Requirements, back-of-envelope capacity estimation for a celebrity-scale fan-out read problem, a high-level design diagram, a deeper deployment architecture, sequence diagrams for uploading and viewing a story, and an entity-relationship diagram built around a hard 24-hour TTL - with the reasoning an interviewer expects behind every box and arrow.

100MStories posted / day
150:1View : upload ratio
24hHard TTL expiry
Clientposts a story Upload + transcoderesize / re-encode CDN edge cacheserves every view Object storagemedia, durable TTL expiryauto-delete @ 24h metadata expires, media follows

1. Clarify requirements before drawing any box

Stories are defined by one property that changes everything downstream: content is meant to disappear. That single constraint - a hard 24-hour lifetime - drives the data model, the caching strategy, and the deletion pipeline more than any other requirement.

Functional requirements

Post a storyUpload a photo or video that auto-expires exactly 24 hours after posting - no manual action required.
Story ringHorizontally scrollable ring of followed accounts, unseen stories first, then ordered by recency/affinity.
Per-viewer view trackingEvery view is recorded so the poster can see who viewed their story and when.
Auto-expiryBoth the story metadata and the underlying media are removed at the 24h mark without any human intervention.

Non-functional requirements

Fast ring loadThe story ring is the first thing opened on every app launch; target <200ms p99.
Celebrity-scale fan-outOne story can be viewed by tens of millions of followers within hours - reads must scale horizontally, not through one hot record.
Write-once, read-many mediaEach media file is uploaded once and viewed thousands to millions of times - CDN/edge caching does most of the work.
Eventual consistency for counters, tight bound on expiryView counts can lag by seconds; a story showing up past its 24h window cannot.
Explicitly out of scope Story replies/DM threading, the close-friends list UI, and highlights (stories persisted past 24 hours) are out of scope for the core design - highlights are addressed as an extension in the deep-dive section, since they change the durability model without changing the ephemeral pipeline itself.

2. Back-of-the-envelope capacity estimation

Uploads are cheap and rare compared to views. The number that decides almost every architecture choice below is the view:upload ratio - it is what rules out fanning writes out to followers and forces a pull-based, cache-first read path.

MetricAssumptionResulting estimate
Daily active users500 million DAUBaseline for every ratio below
Stories posted (writes)~20% of DAU post a story/day100M stories/day → ~1,160 writes/sec avg, ~3,500/sec peak
Views per story (skewed)~150 views/story average; top creators reach millions within hours100M stories × 150 ≈ 15 billion view events/day
View-event QPS (the hot path)15B events / 86,400s, ~3x multiplier at peak hours~174,000/sec avg, ~500,000+/sec peak - a 150:1+ read:write ratio, spiking far higher per hot story
Blended media size70% photo (~200KB compressed) + 30% video (~5MB, 15s clip)0.7×200KB + 0.3×5MB ≈ 1.64MB average per story
Rolling storage window100M stories/day × 1.64MB, retained ~27h (24h visible + safety buffer)~164TB/day ingested → ~185TB resident in the ephemeral tier at steady state
CDN egress bandwidth15B views/day × ~800KB avg delivered (thumbnails + adaptive video chunks, not full re-downloads)~12PB/day served at the edge; origin fetches only cache misses, well under 5% of that
Why this matters A 150:1 read:write ratio that spikes into the millions for a single celebrity story is the number that rules out any design where posting a story triggers a write into every follower's feed. It forces a pull model: store the media once, cache it aggressively at the edge, and let every viewer's request resolve against the same cached object instead of a per-follower copy.

3. High-level design (HLD)

The HLD splits cleanly into a write path (post a story), a read path (load the ring, fetch media), and two side pipelines that must never block either path: view tracking and TTL expiry.

Clientapp / web API gateway / edgeauth, rate limit Upload servicewrite path Transcode pipelineresize / re-encode Object storage + CDNmedia, durable Metadata servicestory records (DB) TTL expiry sweepdeletes expired rows Ring assembly svcread path Cache (Redis)ring + hot stories View-tracking svcfire-and-forget Kafka (view events)durable queue View-count storeasync aggregator
Stateless servicesFast-path infraDurable storageAsync / scheduled

What each box owns

Client & API gateway/edge

The gateway terminates TLS, authenticates the request, and applies per-user upload rate limits before anything reaches the write path. It also fetches media directly from the CDN rather than proxying it through the app tier - the dashed arc in the diagram shows media flowing straight from object storage back to the client, never through the ring assembly service.

Upload service, transcode pipeline & object storage + CDN

The upload service accepts the raw photo/video and hands it to the transcode pipeline, which resizes photos and re-encodes video into adaptive-bitrate chunks plus a thumbnail. The processed media lands in object storage behind a CDN, which is what absorbs the celebrity fan-out - the file is written once and served from edge caches for every subsequent view.

Metadata service & TTL expiry sweep

The metadata service is the source of truth for each story's expires_at, view count, and active flag. The expiry sweep runs independently (native TTL or a scheduled job, see the deep dive) and only ever deletes rows that are already invisible to reads - the read path filters on expires_at regardless of whether physical deletion has happened yet.

Ring assembly service & cache

On every app open, this service resolves "which of the accounts I follow have an active story right now, and which have I not seen yet." It checks the Redis cache first; a hit returns in low single-digit milliseconds, and only a miss touches the metadata database, which is what keeps the p99 latency target achievable at 500M DAU.

View-tracking service, Kafka & view-count store

The client publishes a view event the moment a story starts playing, but never waits for an acknowledgment - it's a fire-and-forget call into the view-tracking service, which drops the event onto a Kafka topic. A stream processor consumes the topic and updates the view count and per-viewer "who viewed" list asynchronously, decoupled entirely from the read-hot ring path.

4. Detailed architecture diagram

The architecture diagram answers the one question that decides whether this design survives a celebrity posting a story: how do you serve tens of millions of reads against one piece of media without hammering a single database row or fanning writes out to every follower?

Edge layer (global) GeoDNS / Anycast CDN edge PoPscache-first, immutable objects API gateway + L7 LB Rate limiterper-user upload throttle Region: us-east-1 (primary) Upload svc ×8 pods Ring assembly ×16 pods Metadata shards (by user_id) Redis cluster (ring + hot) Region: ap-south-1 (active-active) Upload svc ×4 pods Ring assembly ×8 pods Metadata shards (regional)async cross-region replicafor read-your-own-story checks Object storage tier Multi-region bucket (S3-class) lifecycle rule: delete ~26h afterupload (1h buffer past metadata expiry) View-event pipeline Kafka topic Stream workers windowed aggregation, batched writes -decoupled from the read-hot ring path TTL expiry mechanism Native TTL attribute + sweep worker deletes expired metadata rows, thenemits a storage-lifecycle delete trigger
DecisionChoiceReasoning
Fan-out modelPull (cache + CDN), not push to every follower's feedA celebrity story can be viewed tens of millions of times in hours; writing a copy into every follower's feed at post time doesn't scale, while one cached CDN object serves unlimited readers.
Metadata sharding keyHash of story owner's user_idEvery ring query is "give me this followee's active stories" - a natural per-owner lookup; the ring cache absorbs the scatter-gather across a viewer's many followees.
TTL deletion mechanismNative TTL attribute, backed by a sweep workerNative TTL is free and low-maintenance for bulk cleanup; the sweep worker exists for cases where TTL's built-in deletion lag is unacceptable (see deep dive 1).
CDN caching strategyImmutable, content-addressed URLs with long-lived cache-controlMedia never changes after upload, so edge caches can hold it aggressively without any invalidation logic - only new uploads get new URLs.

5. Sequence diagrams for the two critical flows

The upload flow is dominated by processing steps that must finish before the story becomes visible; the view flow is dominated by a cache check that must resolve in milliseconds because it runs 150 times more often than an upload.

5.1 Post a story (write path)

Client Upload svc Transcode Object storage+CDN Metadata svc Cache 1. POST /stories {media, mimeType} 2. enqueue transcode job 3. resize photo / re-encode + thumbnail video 4. PUT processed media 5. media_url (CDN path) 6. INSERT story {media_url, expires_at = now+24h} 7. ack, story_id 8. invalidate followers' ring cache (fire-and-forget) 9. 201 Created {story_id}

Step 3 is the slowest part of the whole flow and runs entirely inside the transcode pipeline with no network round trip drawn, which is why upload latency is dominated by video re-encoding, not by any database write. Step 8 is deliberately dashed and fire-and-forget: invalidating every follower's cached ring is an optimization for freshness, not a correctness requirement, so the client's 201 response in step 9 never waits on it.

5.2 View the story ring (read path, with cache miss)

Client Ring assembly svc Cache Metadata DB CDN / storage Kafka 1. GET /stories/ring 2. GET ring:{viewer_id} 3. MISS 4. SELECT stories WHERE owner IN (following) AND expires_at>now 5. rows (active stories only) 6. SET ring:{viewer_id} (TTL ~30s) 7. 200 OK {ring, unseen-first} 8. GET media (direct CDN fetch) 9. publish view event (fire-and-forget)

On a cache hit, steps 3-6 collapse into a single Redis read and the ring returns in a few milliseconds - the path that must hit the <200ms p99 target. Step 8 goes straight from client to the CDN, bypassing the ring assembly service entirely, which is exactly what lets one celebrity story absorb millions of views without touching the metadata database again. Step 9 never blocks playback. The TTL sweep that eventually deletes this same story runs completely independently of this request path - on its own schedule, against the metadata database directly - so an in-flight ring load is never affected by expiry timing, and a story disappears from new ring loads immediately once expires_at passes even if its row has not been physically deleted yet.

6. Entity-relationship (ER) diagram and schema

The data model has to answer three questions: what marks a story as visible right now, how are views recorded without write-amplifying a single hot story row, and how is the 24-hour deletion actually implemented at the storage layer.

stories PK id BIGINT FK user_id BIGINT media_url TEXT media_type ENUM created_at TIMESTAMP expires_at TIMESTAMP view_count BIGINT is_active BOOLEAN story_views PK id BIGINT FK story_id BIGINT FK viewer_id BIGINT viewed_at TIMESTAMP UQ (story_id, viewer_id) dedupes repeat views expiry_jobs PK id BIGINT FK story_id BIGINT run_at TIMESTAMP status ENUM optional: only needed without native TTL 1N 11 one story has many story_views; one story has at most one pending expiry_jobs row (queue-based approach only)

Key modeling decisions

expires_at drives both reads and deletionEvery ring query filters on expires_at > now(); the same column doubles as the native TTL attribute if the storage engine supports it.
view_count is denormalized and eventually consistentUpdated asynchronously by the stream aggregator reading off Kafka, never incremented synchronously on the view request path.
story_views is append-only with a dedupe constraintThe (story_id, viewer_id) uniqueness constraint collapses repeat views from the same viewer while still preserving the timestamp for the "who viewed" list.
expiry_jobs is optional, not coreNeeded only when the storage layer has no native per-row TTL; each row is claimed, processed, and deleted by the sweep worker, so its steady-state size stays near zero.
Storage choiceUse whenWatch out for
Sharded relational (Postgres, by user_id)You want a strict uniqueness constraint on (story_id, viewer_id) for view dedupe and simple transactional writes on upload.No native per-row TTL, so it needs the separate expiry_jobs table and a sweep worker to actually reclaim storage.
NoSQL wide-column / KV (DynamoDB, Cassandra)Access pattern is purely key-based ("stories where user_id=X and expires_at>now") at very high write/read throughput.Native TTL deletion has no fixed SLA (DynamoDB can lag up to ~48 minutes past expires_at), so the read path - not the physical delete - is what must enforce visibility.

7. Deep dives interviewers actually probe

How exactly does the 24-hour TTL get enforced?

Two mechanisms, often used together. Native TTL (e.g. DynamoDB's TTL attribute) is free and requires no compute, but its background reaper only guarantees eventual deletion - AWS documents deletion lag of up to roughly 48 minutes past the timestamp, so it cannot be trusted for precise, instant removal. An application-level scheduled sweep job queries for expired rows in batches and deletes them on a tight, predictable schedule, at the cost of running dedicated compute continuously. The key insight is that neither mechanism needs to be exact, because the read path enforces visibility independently: every ring query filters WHERE expires_at > now(), so a row that hasn't been physically deleted yet simply never appears to a viewer.

// DynamoDB item - ttl is the native expiry attribute (epoch seconds)
{
  "story_id": "8f2c...",
  "user_id": "1029384",
  "expires_at": 1783650000,   // application-level filter uses this
  "ttl": 1783650000           // same value, reaper deletes eventually
}
// Read path never trusts physical deletion timing:
// SELECT ... WHERE user_id IN (:following) AND expires_at > :now

How do you handle a celebrity story getting millions of views in hours?

By never fanning the write out in the first place. A push model - writing a copy of the story reference into every follower's feed at post time - turns one upload into tens of millions of writes, and a celebrity gaining or losing followers mid-story makes it worse. The pull model used here stores the media once in object storage behind a CDN and lets every viewer's read resolve against the same cached object; the metadata row for a hot story is read far more often than a normal story, but it's a read, not a write, so it scales by adding cache and edge capacity rather than by fanning out writes. The only extra work for a hot story is that its ring-cache entry and CDN edge cache get pre-warmed and given a longer TTL, since it's known in advance to be disproportionately hot.

How is the story ring ordered, and how is "seen" tracked cheaply?

Ordering is unseen-first, then by a recency/affinity score (recent post time, weighted by how often the viewer engages with that account). The naive way to track "seen" - one row per (viewer, story) pair - creates a write on every single scroll across hundreds of millions of users. Instead, store a single lightweight cursor per (viewer, followee) pair: the highest story ID or timestamp that viewer has already seen from that account. Because a user's own stories are strictly ordered by creation time, comparing the cursor to each candidate story's ID/timestamp is enough to classify it as seen or unseen, with one small write per followee interaction instead of one per story per scroll.

// one row per (viewer, followee), not per (viewer, story)
cursor = getSeenCursor(viewer_id, followee_id)   // { last_seen_story_id }
isUnseen = story.id > cursor.last_seen_story_id
// updating the cursor to the latest story_id covers every older
// story from that followee in a single write

What durability guarantees does the media need during the 24h window?

The metadata row is the actual source of truth for visibility - once expires_at passes or is_active flips false, the story stops appearing in any new ring load, regardless of whether the media file itself has been deleted. The object storage lifecycle rule intentionally deletes the underlying file later (around the 25-26h mark), so a client who already loaded the ring a few seconds before expiry and is mid-playback doesn't hit a broken CDN URL. This is also why the consistency requirements differ per field: view counts can lag by seconds to minutes with zero user-visible harm because they're purely informational, but expiry timing cannot drift by more than a small, bounded amount, because showing an expired story past its promised lifetime is a product and privacy violation, not just a staleness issue.

How would you extend this to support "highlights" that persist past 24h?

Not by clearing expires_at on the original row - that row is already wired into the ephemeral pipeline (native TTL, lifecycle-tagged media, "unseen-first" ring logic), and mixing permanent rows into it re-introduces every problem the TTL design solved. Instead, saving a story to highlights performs a copy-on-save: a new row is written into a separate highlights / highlight_items table with no expires_at, referencing either the same media object promoted to a "retained" storage class (so the ephemeral lifecycle rule no longer deletes it) or a copy of the object into a permanent bucket. The core ephemeral pipeline - upload, transcode, 24h expiry - stays completely unchanged; highlights become an entirely additive read path layered on top.

8. Summary: what a strong answer covers

Named the 150:1+ read:write ratio driving the whole designChose pull-based reads + CDN over feed fan-outSeparated view tracking from the read-hot path Justified TTL vs sweep-job trade-offsExplained why expiry must be tight but view counts can lagExtended to highlights without touching the core pipeline
Interview tip When asked to design Instagram Stories, the strongest signal is recognizing that this is fundamentally a caching and fan-out problem disguised as a CRUD feature: the interesting part isn't storing a story, it's serving one story's media to millions of readers without ever writing to a per-follower copy, and deleting it reliably without ever letting deletion timing lag user-visible correctness.
No comments
Leave a Comment