Loading learning content...
Instagram Stories transformed how people share moments. Launched in 2016 (inspired by Snapchat), Stories introduced ephemeral content—photos and videos that disappear after 24 hours. This seemingly simple concept created entirely new architectural challenges and user behaviors.
Stories by the Numbers:
| Metric | Scale |
|---|---|
| Daily Stories created | 500+ million |
| Daily Stories viewers | 500+ million users |
| Story segments per story (average) | 3-5 segments |
| Story views per day | 10+ billion |
| Peak concurrent story viewers | 50+ million |
Stories differ fundamentally from feed posts:
By the end of this page, you will understand: (1) The story lifecycle from creation to expiration, (2) Story tray ranking and the 'ring' UI system, (3) Sequential viewing mechanics and progress tracking, (4) Interactive features architecture (polls, questions, reactions), (5) View tracking and analytics systems, (6) Close Friends and audience controls, and (7) Storage and cleanup for ephemeral content.
A story segment lives for exactly 24 hours. Understanding this lifecycle is essential for designing the supporting infrastructure.
Story Creation Flow:
Story Data Model:
-- Story Segment: Individual photo/video in a story
CREATE TABLE story_segments (
segment_id BIGINT PRIMARY KEY,
author_id BIGINT NOT NULL,
media_id BIGINT NOT NULL, -- Reference to processed media
media_type ENUM('photo', 'video'),
-- Timing
created_at TIMESTAMP NOT NULL,
expires_at TIMESTAMP GENERATED ALWAYS AS (created_at + INTERVAL '24 hours'),
-- Audience
audience_type ENUM('public', 'close_friends') NOT NULL,
-- Interactive elements (stored as JSON)
stickers JSONB, -- Polls, questions, mentions, hashtags, locations
-- Display
duration_seconds FLOAT DEFAULT 5, -- How long segment displays (photos: 5s, videos: video length)
-- Status
is_expired BOOLEAN DEFAULT FALSE,
is_archived BOOLEAN DEFAULT FALSE, -- Saved to Highlights
FOREIGN KEY (author_id) REFERENCES users(id),
INDEX idx_author_active (author_id, is_expired, created_at)
);
-- Story: Aggregation of segments from one author in 24-hour window
-- This is often computed rather than stored
CREATE VIEW active_stories AS
SELECT
author_id,
MIN(created_at) AS story_start,
MAX(created_at) AS latest_segment,
COUNT(*) AS segment_count,
ARRAY_AGG(segment_id ORDER BY created_at) AS segment_ids
FROM story_segments
WHERE is_expired = FALSE
GROUP BY author_id;
Expiration Handling:
With 500 million stories expiring daily, the expiration system must be robust and efficient:
| Approach | Description | Trade-offs |
|---|---|---|
| TTL-based storage | Use database/cache with automatic TTL expiration | Simple, but may not clean up related data |
| Scheduled job scanning | Periodic job scans for expired stories | Batches well, but delays between expiration and cleanup |
| Delayed queue | Enqueue cleanup task at creation time | Precise timing, but queue must handle 500M/day |
| Lazy expiration | Mark expired on read, cleanup later | No background work, but stale data in storage |
Instagram's Approach: Lazy + Batch Cleanup
# On read: Check expiration lazily
def get_story_segments(author_id: str) -> List[StorySegment]:
segments = storage.get_segments(author_id)
active_segments = []
for segment in segments:
if segment.expires_at <= time.now():
# Mark as expired (lazy)
segment.is_expired = True
mark_for_cleanup(segment.segment_id)
else:
active_segments.append(segment)
return active_segments
# Background job: Clean up expired segments in batches
@scheduled(every_minutes=5)
def cleanup_expired_stories():
expired = db.query(
"""SELECT segment_id, media_id FROM story_segments
WHERE expires_at < NOW() - INTERVAL '1 hour'
AND is_archived = FALSE
LIMIT 10000"""
)
for segment in expired:
# Delete from storage (unless archived)
if not segment.is_archived:
storage.schedule_deletion(segment.media_id)
# Remove segment record
db.delete('story_segments', segment.segment_id)
Stories that expire and aren't archived to Highlights can be deleted from storage. With 24-hour retention and ~80% not being archived, this significantly reduces storage compared to permanent posts. Story media typically uses lower quality/shorter retention storage tiers.
The story tray is the horizontal row of circular profile pictures at the top of Instagram. Each circle (ring) represents a user with an active story. The rings are ranked to show the most relevant stories first.
Story Tray Requirements:
Tray Data Model:
1234567891011121314151617181920212223242526272829
interface StoryRingItem { userId: string; username: string; profilePictureUrl: string; // Story state hasUnseenSegments: boolean; latestSegmentTimestamp: number; segmentCount: number; // Audience info isCloseFriendsStory: boolean; // Green ring instead of gradient // Ranking signals rankScore: number;} interface StoryTray { rings: StoryRingItem[]; userOwnRing?: StoryRingItem; // User's own story (always first if exists) lastUpdated: number; hasMore: boolean; // For pagination on scroll} // API Responseinterface StoryTrayResponse { tray: StoryTray; nextCursor?: string; // For loading more rings on scroll}Tray Ranking Algorithm:
The order of rings is ranked by predicted engagement and relevance:
| Ranking Signal | Weight | Rationale |
|---|---|---|
| Has unseen content | High | Unseen stories always prioritized |
| Recency of latest segment | Medium | Fresher stories are more relevant |
| User interaction history | High | Stories from close friends ranked higher |
| Story completion rate | Medium | Users who finish their stories → engaging content |
| DM frequency | Medium | Story from someone you message often |
| Profile visit frequency | Low | Weak but positive signal |
def rank_story_tray(viewer_id: str, candidates: List[StoryRingItem]) -> List[StoryRingItem]:
"""
Rank story tray for a specific viewer.
Returns rings sorted by descending relevance.
"""
interaction_history = get_interaction_history(viewer_id)
for ring in candidates:
ring.rank_score = compute_ring_score(
ring,
interaction_history,
viewer_id
)
# Sort by: unseen first, then by rank score
return sorted(
candidates,
key=lambda r: (not r.hasUnseenSegments, -r.rank_score)
)
def compute_ring_score(ring: StoryRingItem, history: InteractionHistory, viewer_id: str) -> float:
score = 0.0
# Recency (log decay)
age_hours = (time.now() - ring.latestSegmentTimestamp) / 3600
score += 100 * math.exp(-age_hours / 12) # Half-life of 12 hours
# Interaction signals
score += 50 * history.dm_count_30d.get(ring.userId, 0)
score += 30 * history.likes_given_30d.get(ring.userId, 0)
score += 20 * history.comments_given_30d.get(ring.userId, 0)
score += 10 * history.story_replies_30d.get(ring.userId, 0)
# Close friends boost
if ring.isCloseFriendsStory:
score *= 1.5
return score
Visual Ring States:
| Ring State | Appearance | Meaning |
|---|---|---|
| Gradient ring (unseen) | Colorful gradient ring | New story content to view |
| Grey ring (seen) | Grey/faded ring | All segments have been viewed |
| Green ring | Green ring | Close Friends story (unseen) |
| No ring | Just profile picture | No active story |
| "Add to Story" | Plus icon | User's own story placeholder |
Tray Caching:
The tray must load extremely fast (before the feed):
# Tray is cached per-user with short TTL
def get_story_tray(viewer_id: str) -> StoryTray:
cache_key = f"story_tray:{viewer_id}"
# Try cache first
cached = redis.get(cache_key)
if cached and not is_stale(cached, max_age_seconds=60):
return cached
# Generate fresh tray
following = get_following(viewer_id)
users_with_stories = filter_users_with_active_stories(following)
rings = []
for user_id in users_with_stories:
ring = build_ring_item(user_id, viewer_id)
rings.append(ring)
ranked_rings = rank_story_tray(viewer_id, rings)
tray = StoryTray(
rings=ranked_rings[:50], # Initial load
userOwnRing=build_own_ring(viewer_id),
lastUpdated=time.now(),
hasMore=len(ranked_rings) > 50
)
redis.set(cache_key, tray, ttl=120) # 2-minute cache
return tray
When a user returns to the home feed after viewing stories, the tray updates in-place: viewed stories get grey rings, and the scroll position is maintained. This happens client-side based on view tracking, without a full tray refetch.
Story viewing is fundamentally different from feed scrolling. Stories are consumed as sequential, timed experiences with tap-to-advance navigation.
Viewing UX:
Segment Fetching Strategy:
Users can swipe through hundreds of stories. Pre-fetching is essential for smooth transitions:
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364
class StoryViewerPrefetcher { private loadedStories: Map<string, Story> = new Map(); private preloadQueue: string[] = []; constructor(private trayRings: StoryRingItem[]) {} /** * Called when user enters a story. * Prefetch adjacent stories for smooth navigation. */ onEnterStory(currentUserId: string, currentIndex: number) { // Load current story fully this.loadStory(currentUserId, 'high'); // Prefetch next 2 stories (high priority) const next1 = this.trayRings[currentIndex + 1]?.userId; const next2 = this.trayRings[currentIndex + 2]?.userId; if (next1) this.loadStory(next1, 'high'); if (next2) this.loadStory(next2, 'medium'); // Prefetch previous story (medium priority) const prev = this.trayRings[currentIndex - 1]?.userId; if (prev) this.loadStory(prev, 'medium'); // Background prefetch next 3-5 (low priority) for (let i = currentIndex + 3; i < currentIndex + 6 && i < this.trayRings.length; i++) { const userId = this.trayRings[i]?.userId; if (userId) this.loadStory(userId, 'low'); } } /** * Load a story with all its segments. */ async loadStory(userId: string, priority: 'high' | 'medium' | 'low') { if (this.loadedStories.has(userId)) return; // Fetch story metadata and segment list const story = await api.getStory(userId); this.loadedStories.set(userId, story); // Prefetch segment media for (const segment of story.segments) { this.prefetchMedia(segment.mediaUrl, priority); } } /** * Prefetch media for instant display. */ prefetchMedia(url: string, priority: 'high' | 'medium' | 'low') { const link = document.createElement('link'); link.rel = 'prefetch'; link.href = url; link.as = segment.type === 'video' ? 'video' : 'image'; // Priority hints for browser if (priority === 'high') { link.fetchPriority = 'high'; } document.head.appendChild(link); }}Progress Tracking State:
The story viewer maintains complex state for seamless navigation:
interface StoryViewerState {
// Current position
currentRingIndex: number;
currentSegmentIndex: number;
// Timing
segmentStartTime: number;
isPaused: boolean;
remainingDuration: number; // For resume after pause
// View history (for this session)
viewedSegments: Set<string>; // segment IDs viewed
// Interaction state
activeInteraction: 'poll' | 'question' | 'slider' | null;
// Reply state
replyDraft: string;
isReplying: boolean;
}
Segment View Recording:
View events are tracked for analytics and the "Seen By" feature:
def record_segment_view(segment_id: str, viewer_id: str, view_context: ViewContext):
"""
Record that a viewer saw a story segment.
Called when segment completes (or viewer exits mid-segment with >3 seconds viewed).
"""
view_event = {
'segment_id': segment_id,
'viewer_id': viewer_id,
'viewed_at': time.now(),
'view_duration_ms': view_context.duration,
'view_source': view_context.source, # 'tray', 'profile', 'reshare'
'completion_rate': view_context.completion_rate,
}
# Write to views table
db.insert('story_views', view_event)
# Update viewer's "seen" state (for ring color)
update_viewer_seen_state(viewer_id, segment_id)
# Async: Update author's view count (batched)
enqueue_view_count_update(segment_id)
With 10+ billion story views daily, writing each view synchronously would overwhelm databases. Views are batched: individual view events go to a write-optimized store (Kafka/Scribe), then aggregated periodically into view counts. The 'Seen By' list uses dedicated infrastructure for the recent-views query.
Stories introduced rich interactive elements that transform passive viewing into active engagement. These features have unique backend requirements.
Interactive Sticker Types:
| Sticker | Interaction | Data Generated |
|---|---|---|
| Poll | Binary choice vote | Voter ID, choice, timestamp |
| Quiz | Multiple choice answer | Voter ID, answer, correct/wrong |
| Slider | Continuous scale response | User ID, value (0-1), timestamp |
| Question | Text response | User ID, response text, timestamp |
| Countdown | Reminder opt-in | User ID, reminded=true |
| Add Yours | Prompt to reshare | User ID, their story segment ID |
| Mention | User tag | Mentioned user notified |
| Location | Place tag | Location ID, enables discovery |
| Hashtag | Topic tag | Tag, enables discovery |
Poll Implementation:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778
from dataclasses import dataclassfrom typing import Dict, Optionalfrom collections import Counter @dataclassclass PollSticker: poll_id: str segment_id: str question: str options: List[str] # ["Option A", "Option B"] created_at: float @dataclassclass PollVote: poll_id: str voter_id: str option_index: int # 0 or 1 for binary voted_at: float class PollService: def __init__(self, db, cache): self.db = db self.cache = cache async def vote(self, poll_id: str, voter_id: str, option_index: int) -> PollResult: """ Record a vote and return updated poll results. Each user can only vote once (idempotent). """ # Check for existing vote (idempotency) existing = await self.db.get_vote(poll_id, voter_id) if existing: # User already voted - return current results return await self.get_results(poll_id) # Record vote vote = PollVote( poll_id=poll_id, voter_id=voter_id, option_index=option_index, voted_at=time.now() ) await self.db.insert_vote(vote) # Update cached counts (atomic increment) await self.cache.incr(f"poll:{poll_id}:option:{option_index}") await self.cache.incr(f"poll:{poll_id}:total") # Return updated results return await self.get_results(poll_id, viewer_vote=option_index) async def get_results(self, poll_id: str, viewer_vote: Optional[int] = None) -> PollResult: """ Get current poll results with percentages. """ counts = [] total = 0 # Get from cache (fast path) for i in range(2): # Binary poll count = await self.cache.get(f"poll:{poll_id}:option:{i}") or 0 counts.append(count) total += count # Calculate percentages percentages = [ (count / total * 100) if total > 0 else 50 for count in counts ] return PollResult( poll_id=poll_id, option_counts=counts, option_percentages=percentages, total_votes=total, viewer_vote=viewer_vote )Question Sticker Flow:
Questions enable text responses that appear in the author's inbox:
Viewer sees question sticker
→ Taps to respond
→ Types response (300 char limit)
→ Submits
→ Response stored in author's story inbox
→ Author can view responses, share to their story (with attribution)
Question Response Privacy:
| Privacy Aspect | Behavior |
|---|---|
| Author sees responder identity | Yes, always |
| Public sees responder identity | Only if author reshares |
| Responder can delete | Yes, before reshare |
| Author can reshare anonymously | Yes, option to hide responder |
Slider Sticker:
Sliders provide continuous feedback with emoji anchors:
@dataclass
class SliderResponse:
slider_id: str
user_id: str
value: float # 0.0 to 1.0
responded_at: float
async def submit_slider_response(slider_id: str, user_id: str, value: float):
# Validate value
if not 0.0 <= value <= 1.0:
raise ValueError("Slider value must be between 0 and 1")
# Store response
response = SliderResponse(
slider_id=slider_id,
user_id=user_id,
value=value,
responded_at=time.now()
)
await db.upsert('slider_responses', response) # Upsert for idempotency
# Update running average (for showing to author)
await update_slider_average(slider_id, value)
async def get_slider_stats(slider_id: str) -> SliderStats:
responses = await db.query('slider_responses', slider_id=slider_id)
values = [r.value for r in responses]
return SliderStats(
response_count=len(values),
average=sum(values) / len(values) if values else 0.5,
distribution=compute_histogram(values, bins=10)
)
Interactive sticker data (poll votes, question responses) is stored separately from the story segment and has different retention. Story media expires at 24 hours, but poll results and question responses may be retained longer for analytics or if the author reshares. The sticker_id links responses to the original segment even after expiration.
Story authors see exactly who viewed their story via the "Seen By" list. This feature requires efficient tracking and querying of billions of daily views.
View Data Requirements:
| Requirement | Challenge |
|---|---|
| Track every view | 10+ billion events daily |
| Show recent viewers | Real-time query for viewer list |
| Viewer count per segment | Aggregated counts |
| Viewer recency | Sort by view time |
| Handle duplicate views | Deduplicate within 24h window |
| Clean up with story | Expire view data with story |
View Storage Architecture:
Views Table Schema:
-- Primary views storage
CREATE TABLE story_views (
segment_id BIGINT,
viewer_id BIGINT,
viewed_at TIMESTAMP,
-- View metadata
view_duration_ms INT,
view_source ENUM('tray', 'profile', 'reshare', 'mention'),
PRIMARY KEY (segment_id, viewer_id), -- Dedup built-in
INDEX idx_segment_viewers (segment_id, viewed_at DESC) -- For Seen By list
-- TTL for automatic cleanup
-- Expire with story (24h) plus grace period
) WITH TTL = INTERVAL '48 hours';
-- View counts (cached/aggregated)
CREATE TABLE story_view_counts (
segment_id BIGINT PRIMARY KEY,
view_count INT DEFAULT 0,
unique_viewer_count INT DEFAULT 0,
last_updated TIMESTAMP
);
Seen By Query:
async def get_seen_by_list(
segment_id: str,
author_id: str,
limit: int = 50,
cursor: Optional[str] = None
) -> SeenByResponse:
"""
Get list of users who viewed a story segment.
Only author can access this list.
"""
# Verify author owns segment (authorization)
segment = await get_segment(segment_id)
if segment.author_id != author_id:
raise PermissionError("Only author can view Seen By list")
# Query viewers with pagination
if cursor:
cursor_data = decode_cursor(cursor)
views = await db.query(
"""SELECT viewer_id, viewed_at FROM story_views
WHERE segment_id = :segment_id
AND viewed_at < :cursor_time
ORDER BY viewed_at DESC
LIMIT :limit""",
segment_id=segment_id,
cursor_time=cursor_data['viewed_at'],
limit=limit + 1
)
else:
views = await db.query(
"""SELECT viewer_id, viewed_at FROM story_views
WHERE segment_id = :segment_id
ORDER BY viewed_at DESC
LIMIT :limit""",
segment_id=segment_id,
limit=limit + 1
)
# Check for next page
has_more = len(views) > limit
views = views[:limit]
# Hydrate viewer profiles
viewer_profiles = await batch_get_profiles([v.viewer_id for v in views])
return SeenByResponse(
viewers=[
SeenByEntry(
user=viewer_profiles[v.viewer_id],
viewed_at=v.viewed_at
)
for v in views
],
view_count=await get_view_count(segment_id),
has_more=has_more,
next_cursor=encode_cursor(views[-1]) if has_more else None
)
Story Insights for Creators:
Business and creator accounts see extended analytics:
| Metric | Description | Data Source |
|---|---|---|
| Impressions | Total views (non-unique) | View event count |
| Reach | Unique viewers | Distinct viewer_id count |
| Exits | Views where user left story | View events with early exit flag |
| Replies | Text replies sent | Reply events |
| Shares | Reshares to other stories/DMs | Share events |
| Profile visits | Visits from story | Attribution tracking |
| Website clicks | Swipe-ups (if eligible) | Link click events |
| Sticker interactions | Poll votes, question responses | Sticker event aggregates |
Snapchat notifies when users screenshot stories. Instagram chose NOT to implement this (except briefly). This is a product/privacy trade-off: notification protects content but also reduces sharing inhibition. Instagram prioritized engagement over content protection.
Not all stories are for all followers. Instagram provides granular audience controls, with Close Friends being the most prominent.
Close Friends Feature:
Close Friends Data Model:
-- Close Friends list (per user)
CREATE TABLE close_friends (
user_id BIGINT,
friend_id BIGINT,
added_at TIMESTAMP,
PRIMARY KEY (user_id, friend_id),
INDEX idx_friend (friend_id) -- For "am I on their list" check
);
-- Query: Get user's close friends list
SELECT friend_id FROM close_friends WHERE user_id = :user_id;
-- Query: Check if I can see user X's close friends story
SELECT 1 FROM close_friends
WHERE user_id = :story_author_id AND friend_id = :viewer_id;
Audience Control Enforcement:
class StoryAudienceChecker:
"""Enforces story visibility based on audience settings."""
async def can_view_story(self, segment: StorySegment, viewer_id: str) -> bool:
"""
Check if viewer can see this story segment.
"""
author_id = segment.author_id
# Author can always see their own stories
if viewer_id == author_id:
return True
# Check account privacy
author = await get_user(author_id)
if author.is_private:
# Must be an approved follower
if not await is_approved_follower(viewer_id, author_id):
return False
# Check Close Friends restriction
if segment.audience_type == 'close_friends':
if not await is_close_friend(author_id, viewer_id):
return False
# Check if viewer is blocked
if await is_blocked(author_id, viewer_id):
return False
return True
async def filter_visible_stories(self, segments: List[StorySegment], viewer_id: str) -> List[StorySegment]:
"""
Filter list of segments to only those viewer can see.
"""
visible = []
for segment in segments:
if await self.can_view_story(segment, viewer_id):
visible.append(segment)
return visible
Hide Story From Feature:
Users can hide their stories from specific followers:
CREATE TABLE story_hidden_from (
user_id BIGINT, -- Story author
hidden_from_id BIGINT, -- Follower who can't see stories
created_at TIMESTAMP,
PRIMARY KEY (user_id, hidden_from_id)
);
-- In can_view_story:
if await is_hidden_from(author_id, viewer_id):
return False
| Audience | Who Can View | Ring Color | Visible In Tray |
|---|---|---|---|
| Public | All followers (and non-followers if public account) | Gradient | All followers |
| Close Friends | Only users on Close Friends list | Green | Only Close Friends |
| Hidden from specific users | Followers minus hidden list | Gradient | Everyone except hidden |
| Direct send | Specific recipients via DM | N/A (DM) | N/A |
Checking audience permissions for every story view would be expensive. Instagram caches: (1) follower relationships, (2) close friends lists, (3) block lists, and (4) hide lists. These are invalidated on relationship changes and have short TTLs for eventual consistency.
Story Highlights allow users to preserve ephemeral stories indefinitely by collecting them into themed groups on their profile. Story Archive automatically saves all stories for the user's reference.
Highlights Structure:
CREATE TABLE story_highlights (
highlight_id BIGINT PRIMARY KEY,
user_id BIGINT NOT NULL,
title VARCHAR(100),
cover_media_id BIGINT,
created_at TIMESTAMP,
updated_at TIMESTAMP,
sort_order INT, -- Position on profile
INDEX idx_user (user_id, sort_order)
);
CREATE TABLE highlight_segments (
highlight_id BIGINT,
segment_id BIGINT,
added_at TIMESTAMP,
sort_order INT,
PRIMARY KEY (highlight_id, segment_id),
FOREIGN KEY (highlight_id) REFERENCES story_highlights(highlight_id),
FOREIGN KEY (segment_id) REFERENCES story_segments(segment_id)
);
archive vs. Highlight:
| Feature | Archive | Highlight |
|---|---|---|
| Visibility | Private (author only) | Public (on profile) |
| Automatic | Yes, all stories saved | No, manual curation |
| Expiration | Never | Never |
| Organization | Chronological | User-defined groups |
| Cover image | N/A | User-selectable |
Preventing Storage Explosion:
If all users archive all stories, storage would grow unbounded. Instagram's approach:
Adding to Highlights Flow:
async def add_to_highlight(user_id: str, highlight_id: str, segment_id: str):
"""
Add a story segment to a highlight.
Can be done while story is active or from archive.
"""
# Verify ownership
segment = await get_segment(segment_id)
if segment.author_id != user_id:
raise PermissionError("Can only add own segments to highlights")
highlight = await get_highlight(highlight_id)
if highlight.user_id != user_id:
raise PermissionError("Can only add to own highlights")
# Mark segment as archived (prevent deletion)
await db.update('story_segments', segment_id, is_archived=True)
# Add to highlight
max_order = await get_max_highlight_order(highlight_id)
await db.insert('highlight_segments', {
'highlight_id': highlight_id,
'segment_id': segment_id,
'added_at': time.now(),
'sort_order': max_order + 1
})
# Update highlight's updated_at
await db.update('story_highlights', highlight_id, updated_at=time.now())
Highlights effectively allow users to curate a 'portfolio' of stories on their profile. Unlike the permanent feed grid, Highlights can be reordered, renamed, and themed. Many creators use Highlights strategically—for testimonials, product categories, FAQs, etc. This transforms ephemeral stories into permanent profile content.
Stories introduced an entirely new content paradigm requiring novel architectural solutions. Let's consolidate the key patterns:
What's Next: Explore Algorithm
The Explore page represents Instagram's most challenging recommendation problem—surfacing content from accounts the user doesn't follow. We'll explore:
You now understand the architecture behind Instagram Stories—the ephemeral layer that transformed social sharing. The patterns here (TTL storage, sequential media, interactive overlays, view tracking) apply to any ephemeral content feature. Next, we tackle the recommendation challenge of the Explore page.