InstagramStories viewer

100%

Stories viewer.

A row of avatar rings sits above the feed. Tapping one opens a full-screen viewer that plays that person's short photos and clips one after another, each with its own slice of a progress bar, then moves on to the next person. Holding pauses, tapping skips, and the ring goes grey once everything in it has been watched.

Advanced46 minUpdated 2 Oct 2026

Builds on Infinite photo feed.

Requirements exploration.

The web stories experience of an Instagram-like photo product: the tray of rings above the feed and the full-screen viewer it opens. The server decides who is in the tray and in what order, and returns each person's stories as a reel. The client plays them on a steady clock, keeps the next one ready, pauses when it should and remembers what was seen.

Clarifying questions

Q1
Use casesWhat does a viewer do here?
Opens someone's stories from the tray or a shared link, watches them advance, skips, pauses, likes, replies, moves to the next person and closes. Making stories is a separate flow.
Q2
MediaWhat can a story item be?
A still image or a short video. Assumption for the exercise: an image stays on screen for 5 seconds, and a video plays for its own length, which the server caps at 15 seconds.
Q3
OrderWho decides the order of the tray?
The server. The client never reorders it, not even when a ring turns grey; a fresh order arrives with the next tray load.
Q4
SeenWhat does seen mean, and who needs to know?
An item is seen once its first frame has been on screen. The viewer's own rings need it at once; the author's viewer list within a few seconds.
Q5
DevicesPhone or desktop?
Both, in the browser. On a phone the viewer fills the screen and touch drives it; on desktop it sits on a dark backdrop with the previous and next people as smaller cards beside it.
Q6
RepliesWhere do replies go?
Into a direct message to the author with the story attached. The viewer only sends it; the conversation belongs to the messaging flow.

Functional requirements

#1The tray shows people with active stories in the server's order, a coloured ring for unseen items and a grey one otherwise.
#2Tapping a ring opens a full-screen viewer at the person's first unseen item (or the first if all are seen), one progress segment per item.
#3Images advance after their display time and videos when they end; after the last item the viewer moves to the next person, and after the last person it closes.
#4User can go back and forward by tapping either side, with the arrow keys or with buttons; desktop shows neighbouring people as cards.
#5Holding pauses; a Pause button pauses until pressed again; typing a reply, opening a menu or hiding the tab also pause.
#6Videos start muted with their poster shown at once; the sound toggle is remembered for the session.
#7Each story has its own URL, so a link opens it over the feed, refresh keeps it, and Back closes the viewer.
#8User can like a story and send a reply, which arrives in the author's direct messages.
#9Items and rings turn seen at once in the UI, and the server learns of it even if the tab closes right after.
#10A story that expired or was deleted while open shows a short notice and is skipped.

Non-functional requirements

No.AreaRequirementTarget / measure
01ResponsivenessThe next story appears the moment it is due.
Next item on screen < 100 ms when preloadedFirst frame < 1 s from a tap on 4GCold deep link first frame < 2.5 s
02TimingThe bar never jumps or runs on while nothing is shown.
Elapsed drift < 1 frame per item0 ms counted while paused or hidden
03Data usePreloading stays within a budget that depends on the connection.
≤ 4 MB ahead on unmetered≤ 1 MB ahead on cellular or unknownPosters only with data saving on
04AccessibilityAuto-advancing media can be paused by anyone; the viewer works with keyboard and screen reader.
WCAG 2.2 AA, including 2.2.2APG modal dialog pattern
05Reliability of seen stateReceipts survive tab close, retries and duplicates.
Receipts sent on hide and closeServer write idempotent
06SmoothnessThe bar animates without stealing the main thread from gestures.
60 fps barINP < 200 ms on tap and hold

Assumptions for the exercise

Image display time
5 s
product choice
Video cap
15 s
enforced at upload
People in a tray
6–30
first 12 returned, the rest paged
Items per person
1–10
average 4

Scope

In scope
Tray of rings above the feed, seen and unseen
Viewer dialog, segment bar, gestures and keyboard
The playback clock and every reason to pause
Preloading ahead within a byte budget
Seen receipts, batching and sync back to the tray
Story URLs, deep links and Back
Like, and sending a reply
Expired and deleted items
Out of scope
Creating stories (camera, stickers, text)separate flow
The DM thread a reply lands inwhatsapp/chat-thread
Ranking the trayserver black box
Highlights on profilesseparate design
Adaptive bitrate streamingnetflix/video-player
Was this section helpful?

High level design.

Three things move on their own: time, bytes and receipts, each with one owner. PlaybackClock owns time and alone ends an item; MediaPreloader owns bytes and fetches nothing outside its window; SeenTracker owns receipts and decides when they leave. The viewer only turns gestures into actions and pause reasons.

Component diagram

Click a card to see its block and connections in the diagram.

Stories viewer components

Stories viewer components. The numbered component cards that follow describe each part.
Stories viewer componentsComponents: 1. StoriesRoute (Owns the URL. Opens the viewer over the feed for /stories/:username/:itemId, replaceStates as it moves, closes it on Back.), 2. StoryTray (The row of avatar rings above the feed, in server order. Draws rings unseen or seen from the store focus returns here on close.), 3. StoryViewer (The modal dialog: segment bar, media surface, tap and hold zones, keyboard, neighbour cards, Pause, Mute, reply and like. Turns gestures into actions and pause reasons.), 4. PlaybackClock (The one clock for the current item. Counts elapsed time only while no pause reason is set, reads video time from the element, raises itemEnded once.), 5. MediaPreloader (Keeps a small window of media ready ahead of the cursor within a byte budget, decodes images early, aborts what leaves the window.), 6. Stories Store (Tray, reels by user id, cursor, local seen ids, like intents. Derives each ring from item seen flags.), 7. SeenTracker (Queues seen receipts and flushes batches with fetch keepalive, including on close and hide. A failed batch rides with the next.), 8. Stories Service (The only caller of the tray, reel, like and reply endpoints. Adds CSRF, times out reads at 8 s, returns typed errors.), 9. Stories API (Black box. Orders the tray, returns reels with signed URLs, stores receipts and likes, hands replies to messaging.), 10. Media CDN (Serves images, videos and posters from media.example.net behind short-lived signed URLs.).

Browser

1StoriesRoute/stories/:username/:itemId over the feed

addReason · removeReason

itemEnded

next · prev · like · reply

cursor changed

GET media (abortable)

itemShown

POST /api/stories/seen

loadTray · loadReels

GET tray · GET reels

ring states

open(username)

mount over feed

3StoryViewer
SegmentBarone segment per itemMediaSurfacetap zones · hold = pauseNeighbourCardsprevious and next person (desktop)FooterReplyBox · Like · Pause · Mute

2StoryTray
rings in server order · focus target on close

4PlaybackClock
elapsedMs · pauseReasons set

5MediaPreloader
window · AbortController each

6Stories Store
localSeen (kept until the batch is acked)

7SeenTracker
seenQueue · flush at 10 / 10 s / hide / close

8Stories Service

9Stories API

10Media CDN

The tray

Each ring is a button whose state is computed from the store's items, so it changes the moment the last one is seen.

Tray above the feed

The tray is embedded in the server-rendered feed HTML, so rings never pop in late. Unseen rings come first because the server ordered them, not the client.

First load. Rows: 6 items. Row “Stories”: 1. Your story by Priya S. [image 1:1] 2. juno.climbs · 5 stories by Juno C. [image 1:1] Unread. 3. harbor.kitchen · 3 stories by Harbor K. [image 1:1] Unread. 4. oskar.draws · 2 stories by Oskar D. [image 1:1] Unread. 5. ferncraft · Seen by Fern C. [image 1:1] 6. mira.walks · Seen by Mira W. [image 1:1] Note on item:juno: aria-label: juno.climbs, 5 stories, unseen

  1. Ring row2StoryTray
  2. Ring state from seen flags6Stories Store

The viewer

The mock's scrubber stands for the current segment; the numbers beside it show the cursor and the pause reasons. The segment strip in the data model draws the whole row.

Watching juno.climbs

The photo is still decoding. The tiny preview from the reel fills the frame; the clock has not started.

Loading poster. Video player “juno.climbs” by “Story 2 of 5 · 3h”, 0:05 long. Poster: “Current story”. Controls: prev, play (shows play), next, volume, like. Playback: loading (poster and spinner) (“Loading”). Position 0:00 of 0:05 (0%), -0:05 left. Cursor: juno.climbs · 2 of 5. Pause reasons: { } · clock not started.

  1. Tap zones and hold3StoryViewer
  2. Current segment4PlaybackClock
  3. Ready or not yet5MediaPreloader
  4. Like intent6Stories Store

Every reason to pause

An isPaused flag breaks when two causes overlap: Priya holds while the reply box has focus, lets go, and the story plays under her keyboard. Pause is a set: each cause adds and removes only its own name, and the clock runs only when the set is empty.

Pause reasons

ReasonAdded whenRemoved whenOwner
holdA press on the surface lasts longer than 200 mspointerup or pointercancelStoryViewer
buttonPause button or SpaceThe same button or Space againStoryViewer
replyFocus enters the reply boxFocus leaves it (sent or dismissed)StoryViewer
menuThe more menu, share sheet or report dialog opensIt closesStoryViewer
hiddenvisibilitychange with visibilityState hiddenvisibilitychange back to visiblePlaybackClock
blockedA video's play() promise rejects (autoplay refused)The viewer taps Play, which retries play() inside the gesturePlaybackClock

The viewer's states

States of3StoryViewer

The viewer's states. 7 states, 19 transitions. The table below lists them.
The viewer's statesThe states of StoryViewer. 7 states, 19 transitions. The table below lists them.

ring tapped or deep link / pushState /stories/:u/:id

media ready [reasons empty] / start clock

media ready [reasons not empty]

404, 410 or missing from the reel

1.5 s or Next / itemIdx + 1

reason added / stop clock

reason added or removed [set still not empty]

reason removed [set now empty] / restart clock

video waiting

video playing

reason added

itemEnded [another item or person] / mark seen · advance cursor
tap side or arrow key / cursor ± 1 · replaceState

tap side or arrow key [only hold or button set] / keep button · clear hold

itemEnded [last item of last person] / flush receipts · focus ring
Escape, close or Back / abort window · flush receipts · focus ring

close / as close

close / as close

close / as close

Closed (tray)

Loading item

Playing

Paused (reasons ≠ ∅)

Buffering

Item unavailable

Viewer closed

5 steps. The happy path through one person's reel.

Paused is one state with a set of reasons: another reason is a self-loop, and only removing the last one leaves. Buffering is separate because the media caused it, and it ends on the media's playing event.

Transitions of The viewer's states
From → ToEventGuardAction
Closed (tray) → Loading itemring tapped or deep linkpushState /stories/:u/:id
Loading item → Playingmedia readyreasons emptystart clock
Loading item → Paused (reasons ≠ ∅)media readyreasons not empty
Loading item → Item unavailable404, 410 or missing from the reel
Item unavailable → Loading item1.5 s or NextitemIdx + 1
Playing → Paused (reasons ≠ ∅)reason addedstop clock
Paused (reasons ≠ ∅) → Paused (reasons ≠ ∅)reason added or removedset still not empty
Paused (reasons ≠ ∅) → Playingreason removedset now emptyrestart clock
Playing → Bufferingvideo waiting
Buffering → Playingvideo playing
Buffering → Paused (reasons ≠ ∅)reason added
Playing → Loading itemitemEndedanother item or personmark seen · advance cursor
Playing → Loading itemtap side or arrow keycursor ± 1 · replaceState
Paused (reasons ≠ ∅) → Loading itemtap side or arrow keyonly hold or button setkeep button · clear hold
Playing → Viewer closeditemEndedlast item of last personflush receipts · focus ring
Playing → Viewer closedEscape, close or Backabort window · flush receipts · focus ring
Paused (reasons ≠ ∅) → Viewer closedcloseas close
Buffering → Viewer closedcloseas close
Loading item → Viewer closedcloseas close
Closed (tray)start
Loading item
Poster or preview showing, clock at 0 and stopped
Playing
Clock running, segment filling
Paused (reasons ≠ ∅)
Clock stopped, elapsed kept
Buffering
Video waiting for data, bar follows currentTime
Item unavailableerror
404 or 410, notice for 1.5 s
Viewer closedend

Sequence diagram

From the tap on a ring to the receipt leaving the page. The current story loads alone; the window is requested only after its first frame.

Opening a reel and watching one story

Opening a reel and watching one story, as an ordered list of steps:
Opening a reel and watching one story21 steps between Priya, StoryTray, StoriesRoute, Stories Store, Stories Service, MediaPreloader, PlaybackClock, SeenTracker, Stories API. The steps are listed as text after the diagram.Stories APISeenTrackerPlaybackClockMediaPreloaderStories ServiceStories StoreStoriesRouteStoryTrayPriyapushState /stories/juno.climbs/j2 · mount dialogfetch + decode j2 alonereasons ∅ → start · elapsed 0cursor → j3 · replaceStatetap juno.climbs ring1open("juno.climbs")2viewerOpened { userId }3loadReels([juno, harbor, oskar])4GET /api/stories/reels?user_ids=u_juno,u_harbor,u_oskar53 reels · signed URLs · seen flags6typed result · reels stored7cursorChanged { juno, 1 }8j2 ready9itemShown(j2)10enqueue { j2, seenAt }11itemEnded(j2) at 5 000 ms12Escape13flush()14POST /api/stories/seen (keepalive)1520416dialog closed · focus on juno ring17
  1. Priya → StoryTray: tap juno.climbs ring
  2. StoryTray → StoriesRoute: open("juno.climbs")
  3. Note over StoriesRoute: pushState /stories/juno.climbs/j2 · mount dialog
  4. StoriesRoute → Stories Store: viewerOpened { userId }
  5. Stories Store → Stories Service: loadReels([juno, harbor, oskar])
  6. Stories Service → Stories API: GET /api/stories/reels?user_ids=u_juno,u_harbor,u_oskar
  7. Stories API → Stories Service (reply): 3 reels · signed URLs · seen flags
  8. Stories Service → Stories Store (reply): typed result · reels stored
  9. Stories Store → MediaPreloader: cursorChanged { juno, 1 }
  10. Note over MediaPreloader: fetch + decode j2 alone
  11. MediaPreloader → PlaybackClock (reply): j2 ready
  12. Note over PlaybackClock: reasons ∅ → start · elapsed 0
  13. PlaybackClock → Stories Store: itemShown(j2)
  14. Stories Store → SeenTracker: enqueue { j2, seenAt }
  15. PlaybackClock → Stories Store: itemEnded(j2) at 5 000 ms
  16. Note over Stories Store: cursor → j3 · replaceState
  17. Priya → StoriesRoute: Escape
  18. StoriesRoute → SeenTracker: flush()
  19. SeenTracker → Stories API: POST /api/stories/seen (keepalive)
  20. Stories API → SeenTracker (reply): 204
  21. StoriesRoute → Priya (reply): dialog closed · focus on juno ring
Was this section helpful?

Data model.

The server sends a light tray and heavier reels. Seen lives on each item; the tray's has_unseen is only a starting value until the reel loads, after which a ring's colour is computed from items and can't drift from them. Time lives in the clock alone, bytes in the preloader alone.

Stories data model

Left to right: the API's shapes, the store, then the clock's, preloader's and tracker's private state.keywordmodelfieldprimitivevalue
// 01 · From the Stories API
// Server order; never sorted.
type Tray = {
users: TrayUser[]
next_cursor?: string
generated_at: timestamp
}
type TrayUser = {
id: string
username: string
avatar_url: url
has_unseen: bool
latest_at: timestamp
item_count: int
}
// Oldest first.
type Reel = {
user_id: string
items: StoryItem[]
}
// URLs are signed and short-lived.
type StoryItem = {
id: string
media_type: ItemType
alt_text: string
url: url
poster_url?: url
preview: string // tiny inline placeholder
duration_ms: int // 5 000 for images
bytes: int
taken_at: timestamp
expires_at: timestamp
seen: bool
liked: bool
}
// 02 · Stories Store
// ringUnseen(user) = some item with !seen && !localSeen.has(id); has_unseen until the reel loads.
type StoriesState = {
tray: TrayUser[]
reels: Map<userId, Reel>
cursor?: Cursor
localSeen: Set<itemId> // seen here, maybe not acked yet
likeIntents: Map<itemId, bool>
soundOn: bool // session only
}
// Null when the viewer is closed.
type Cursor = {
userIdx: int
itemIdx: int
openedFrom: 'tray' | 'link'
}
// 03 · Component state
// PlaybackClock only.
type ClockState = {
elapsedMs: number
lastTickAt?: number
durationMs: number
pauseReasons: Set<PauseReason>
}
// One per item in the window.
type PreloadEntry = {
itemId: string
status: PreloadStatus
bytes: int
controller: AbortController
}
// SeenTracker queue entry.
type SeenReceipt = {
item_id: string
owner_id: string
seen_at: timestamp
}
// Enums
type PauseReason =
| "hold" | "button"
| "reply" | "menu"
| "hidden" | "blocked"
type ItemType =
| "image" | "video"
type PreloadStatus =
| "queued" | "fetching"
| "fetched" | "decoded"
| "aborted" | "failed"

Segments and the preload window

The segment bar, one row per person, each item coloured by what the client knows. Step through Priya's taps: the window slides with the cursor, and items a fast skip leaves behind are aborted.

Seen, playing and preloaded, per item

juno.climbs
  1. photo, 1 item, Seen
  2. photo, 1 item, Playing
  3. video 12 s, 1 item, Downloading
  4. photo, 1 item, Not requested
  5. video 9 s, 1 item, Not requested
  • 2.3 s of 5 s, cursor at 1.46
  • window on this reel, from 1 to 3
harbor.kitchen
  1. photo, 1 item, Downloading
  2. photo, 1 item, Not requested
  3. video 7 s, 1 item, Not requested
oskar.draws
  1. photo, 1 item, Not requested
  2. photo, 1 item, Not requested
  • Seen
  • Playing
  • Decoded and ready
  • Downloading
  • Not requested
  • Aborted
Start

As it starts. 4 steps follow.

One segment per story; the cursor marks how far the current one has played.
Was this section helpful?

Interface definition.

Plain request and response, plus a batched write that must outlive the page. Stories don't change fast enough while watched to justify a live channel; expiry is already a timestamp in the payload.

Transport choice

Used:HTTP / RESTTray, reels, likes and replies are single requests.
Used:fetch with keepalive for seen receiptsLets the last batch outlive the page, and unlike sendBeacon it can carry the CSRF header.
Used:Plain media URLs from the CDNA 15 s single-bitrate clip is a few MB; an adaptive streaming manifest adds a round trip for no gain.
Not used:WebSocket or SSE for new storiesA story posted mid-viewing would not be shown anyway.
Not used:sendBeacon for receiptsKept as a fallback only; it can't set headers and gives no response.

Server to client APIs

1GET/api/stories/tray

The rings, in the server's order. The first result is embedded in the feed's HTML.

Request
Credentials
same-origin cookies
Response200
{
"users": [
{
"id": "u_juno",
"username": "juno.climbs",
"avatar_url": "https://img.example.net/a/u_juno/8b31f0c2e4/150.webp",
"has_unseen": true,
"latest_at": "2026-10-02T07:12:00Z",
"item_count": 5
},
{
"id": "u_harbor",
"username": "harbor.kitchen",
"avatar_url": "https://img.example.net/a/u_harbor/e05d7a19b6/150.webp",
"has_unseen": true,
"latest_at": "2026-10-02T05:40:00Z",
"item_count": 3
}
],
"next_cursor": "tr_2",
"generated_at": "2026-10-02T10:04:31Z"
}
2GET/api/stories/reels?user_ids=u_juno,u_harbor,u_oskar

Reels for up to 3 people: the one opened and the next two, so moving on never waits for a request. Media URLs are signed for 30 minutes.

Request
Credentials
same-origin cookies
Response200
{
"reels": [
{
"user_id": "u_juno",
"items": [
{
"id": "j1",
"media_type": "image",
"alt_text": "Chalk-dusted hands gripping a blue hold",
"url": "https://media.example.net/s/j1/1080.webp?sig=c91e…&exp=1790937300",
"preview": "data:image/webp;base64,UklGR…",
"duration_ms": 5000,
"bytes": 182000,
"taken_at": "2026-10-02T06:58:00Z",
"expires_at": "2026-10-03T06:58:00Z",
"seen": true,
"liked": false
},
{
"id": "j3",
"media_type": "video",
"alt_text": "Juno climbs the overhang and clips the last bolt",
"url": "https://media.example.net/s/j3/720.mp4?sig=4b0a…&exp=1790937300",
"poster_url": "https://media.example.net/s/j3/poster.webp?sig=77d2…",
"duration_ms": 12000,
"bytes": 2250000,
"taken_at": "2026-10-02T07:05:00Z",
"expires_at": "2026-10-03T07:05:00Z",
"seen": false,
"liked": false
}
]
}
]
}
Only the first reel is shown. Items past expires_at are left out, so a reel can come back empty.
3POST/api/stories/seen

A batch of seen receipts, each (viewer, item) inserted into a set, so a repeat changes nothing. Sent with keepalive: true, well under its 64 KiB limit.

Request
Headers
Content-Type: application/jsonX-CSRF-Token: <token>
Credentials
same-origin cookies
{
"batch_id": "sb_01J9ZK4",
"receipts": [
{
"item_id": "j2",
"owner_id": "u_juno",
"seen_at": "2026-10-02T10:05:12Z"
},
{
"item_id": "j3",
"owner_id": "u_juno",
"seen_at": "2026-10-02T10:05:19Z"
}
]
}
Response204
Unknown or expired ids are ignored, so one stale receipt never fails the batch.
4PUT/api/stories/items/{id}/like

Sets the like; DELETE clears it. Both set state rather than toggle, so a retry is harmless.

Request
Headers
X-CSRF-Token: <token>
Response200
{ "item_id": "j3", "liked": true }
5POST/api/direct/story-replies

Sends a reply as a direct message with the story attached; client_id makes a resend safe. The thread belongs to the messaging flow.

Request
Headers
Content-Type: application/jsonX-CSRF-Token: <token>
{
"item_id": "j3",
"owner_id": "u_juno",
"text": "That top move is wild",
"client_id": "c_5f2e81"
}
Response201
{ "thread_id": "t_88213", "message_id": "m_40017" }

Error cases

JSON errors share one shape; MediaPreloader maps the CDN's bare statuses to the same codes.

{ "error": { "code": "item_expired", "message": "This story is no longer available." } }
HTTPTypeBody codeClient behaviour
404error
item_not_found
Notice on the surface, skip after 1.5 s, remove the item from the reel.
410error
item_expired
Same as not found; also prune items whose expires_at has passed locally.
403retry
url_signature_expired
Refetch the reel once for fresh URLs and retry; a second 403 counts as not found.
403error
not_permitted
Priya was removed from the audience: drop the reel and move to the next person.
401challenge
session_expired
Close the viewer, keep receipts queued, open login with the story URL as next.
429retry
rate_limited+ retry_after_s
Receipts only. Keep the batch and wait retry_after_s before the next flush.
5xxnetwork
timeout · offline
Reads: one retry after 1 s, then a Retry button with the clock stopped. Receipts: kept for the next flush.

Client to client communication

The viewer, clock and preloader talk through Stories Store actions; per-frame elapsed time never goes through the store. elapsedMs and pauseReasons in the panel are a snapshot of the clock's own state. Pick a scenario and step through it.

Step 1 of 5
The tray is on screen. Reels for the first three people are fetched; no media yet.
example.com/
Stories
juno.climbs
harbor.kitchen
oskar.draws
Actions dispatched
Nothing yet; the viewer is closed.
Stories Store + clock snapshot
userIdxnull
itemIdxnull
elapsedMs0
pauseReasons{ }
preloadednone
seenQueue[ ]
All actions
viewer/openedStoriesRoute, on a ring tap or a deep link{ userId, itemId }
preload/readyMediaPreloader{ itemId, status: decoded }
clock/itemShownPlaybackClock, on the first painted frame{ itemId, at }
clock/reasonAddedStoryViewer or PlaybackClock{ reason }
clock/reasonRemovedStoryViewer or PlaybackClock{ reason }
clock/itemEndedPlaybackClock, once per item{ itemId }
seen/flushedSeenTracker{ batchId, count }
viewer/closedStoriesRoute, on Escape, close or Back{ focusUserId }

UI component API

StoryViewer is mounted by the route and by profile pages. Timing and preload policy come in as props, so tests can drive a fake clock.

StoryViewer props

PropTypeKindWhy
reelsReel[]dataThe people to play, in tray order.
start{ userId, itemId? }dataWithout itemId, the first unseen item.
imageDurationMsnumberconfigDefault 5 000; videos use their own length.
holdDelayMsnumberconfigDefault 200. Press length that counts as a hold.
preloadPolicy(conn) => { items, bytes }configWindow size and byte budget per connection.
clock() => numberconfigDefaults to performance.now; tests pass a fake.
onItemShown(item) => voideventOnce per item on its first painted frame.
onUserChange(userId, index) => voideventThe route replaces the URL; the live region announces.
onClose(reason: 'escape' | 'button' | 'end' | 'back') => voideventThe route picks history.back or replace, and the ring to focus.
renderFooter(item) => nodeslotReply box and like by default.
<StoryReplyBox
/>
Prop
owner
Default
the current person
Why
Names the field and the destination thread.
Preview
Reply to juno.climbs
Send message
Send
The label and accessible name say who receives it.
Child components
ReplyField
valueonChangeonFocusonBlurenterKeyHint="send"autoComplete="off"
enterKeyHint shows Send on the keyboard's Enter key.
Was this section helpful?

Optimizations.

A rubric picks the areas that make or break this surface for deep dives; the rest get a short list.

Priority rubric

Each area scored 1–3 on three questions; the top four get deep dives.

RankAreaImpact if it failsViewers affectedHow often it mattersScoreDecision
1Timing and pausing3 of 33 of 33 of 39Deep diveEvery second runs on the clock; a jump or an advance under a reply is felt at once.
2Preloading and data use3 of 33 of 33 of 39Deep diveToo much costs data plans; too little shows spinners.
3Accessibility of auto-advancing media3 of 32 of 33 of 38Deep diveAuto-advance without a real pause fails a Level A criterion.
4Seen state and sync2 of 33 of 33 of 38Deep diveA wrong ring colour breaks trust in the tray.
5Video and autoplay2 of 32 of 32 of 36Short listBlocked autoplay is one more pause reason.
6Internationalisation2 of 31 of 32 of 35Short listTap zones and arrows follow reading direction.
7Security and privacy2 of 32 of 31 of 35Short listMostly server work.
8Offline1 of 31 of 31 of 33Short listNo offline viewing, only a clean failure.
1

Deep dive: timing and pausing

The clock adds up time it saw pass while playing instead of measuring from a start time, so pauses, hidden tabs and slow frames all work the same way.

Counted time across a hidden tab

  • now − startedAt
  • Σ deltas while playing
Counted time across a hidden tabMeasuring from the start counts the 20 hidden seconds; accumulating while playing stops at 6 s and carries on.Tab hidden05 s10 s15 s20 s25 s30 s05 s10 s15 s20 s25 s30 sone image story (5 s)5 stories done, 3 never on screennow − startedAtΣ deltas while playingStory time counted (s)Wall-clock time (s)Counted time across a hidden tabMeasuring from the start counts the 20 hidden seconds; accumulating while playing stops at 6 s and carries on.Tab hidden05 s10 s15 s20 s25 s30 s010 s20 s30 sone image story (5 s)5 stories done, 3 never onscreennow − starte…Σ deltas whi…Story time counted (s)Wall-clock time (s)
Priya opens a story at 0 s, switches tabs at 6 s and returns at 26 s.
Data
Wall-clock time (s)now − startedAtΣ deltas while playing
000
666
26266
303010
  • one image story (5 s): Story time counted (s) = 5
  • Tab hidden: Wall-clock time (s) from 6 to 26
  • At 26: 5 stories done, 3 never on screen

PlaybackClock, the core of it

type PauseReason = 'hold' | 'button' | 'reply' | 'menu' | 'hidden' | 'blocked';

const MAX_FRAME_MS = 250; // one frame never counts for more than this

export class PlaybackClock {
  private reasons = new Set<PauseReason>();
  private elapsed = 0;
  private last: number | null = null;
  private ended = false;

  constructor(
    private durationMs: number,
    private onEnd: () => void,
    private paint: (fraction: number) => void,
    private now: () => number = () => performance.now(),
    private video?: HTMLVideoElement,
  ) {}

  add(r: PauseReason) { this.reasons.add(r); this.last = null; this.video?.pause(); }

  remove(r: PauseReason) {
    this.reasons.delete(r);
    if (this.reasons.size === 0) void this.video?.play().catch(() => this.add('blocked'));
  }

  // Called from requestAnimationFrame; the hidden reason stops counting first.
  tick = () => {
    if (this.ended) return;
    if (this.reasons.size === 0) {
      if (this.video) {
        this.elapsed = this.video.currentTime * 1000; // the media is the truth
      } else {
        const t = this.now();
        if (this.last !== null) this.elapsed += Math.min(t - this.last, MAX_FRAME_MS);
        this.last = t;
      }
    }
    this.paint(Math.min(this.elapsed / this.durationMs, 1));
    // A clip can be a few ms shorter than duration_ms, so the media's ended flag also counts.
    if (this.elapsed >= this.durationMs || this.video?.ended) { this.ended = true; this.onEnd(); return; }
    requestAnimationFrame(this.tick);
  };
}
Counted while hidden0 msLargest single step≤ 250 msitemEnded per itemexactly 1
No.ConcernRiskApproachOwned by
01Accumulate, don't subtractElapsed computed as now minus start keeps counting through pauses and hidden tabs, then jumps.Add each frame's delta only while the set is empty; forget the last timestamp on pause so the first frame after adds nothing.4PlaybackClock
02Cap one frameA long task or a frozen device produces one 3-second frame, and the bar leaps.One delta counts for at most 250 ms; the story runs slightly long instead of skipping.4PlaybackClock
03Video reads its own timeA separate timer for a video drifts from the picture during buffering and seeking.For video, elapsed is currentTime read on each animation frame; timeupdate (4 to 66 Hz) is too coarse alone. waiting and playing drive Buffering.4PlaybackClock
04Hidden is a reason, not a hopeBackground tabs throttle timers and stop frames differently per browser.visibilitychange adds hidden at once, so throttling never matters.4PlaybackClock
05End onceA tap on Next in the same frame as the natural end advances two items.The clock sets ended before onEnd; the store ignores itemEnded for a non-current item.4PlaybackClock6Stories Store
06Paint outside ReactSetting state 60 times a second re-renders the dialog and delays taps.The clock writes the fill as a CSS transform through a ref; only cursor changes go through the store.4PlaybackClock3StoryViewer
07Hold versus tapEvery hold ends in a pointerup, which would also count as a tap and skip.pointerdown starts a 200 ms timer. Released before it fires is a tap; after, a hold whose release only removes the reason. pointercancel counts as release.3StoryViewer
2

Deep dive: preloading and data use

A small window slides with the cursor: current item decoded, next item and next person's first item fetched. Its size depends on the connection; what the cursor leaves is aborted.

Preload policy by connection

ConnectionWindow aheadVideo aheadBudget
Unmetered (Wi-Fi or Ethernet, where reported)Next 2 items and the next person's first itemWhole file, preload="auto"≤ 4 MB
Cellular or unknownNext item and the next person's first itemPoster and preload="metadata"; bytes start when it becomes current≤ 1 MB
Data saving on (saveData true)Next item's poster or preview onlyNothing until it is current; then it loads and plays as usual≤ 300 KB
Tab hidden or viewer closedNothing new; in-flight requests abortedNothing0 B

What loads when Priya taps juno.climbs

Scenario 1 of 3: As described.

Timeline as a list

What loads when Priya taps juno.climbs: 7 lanes, from tap to tap+2,800 ms.

  1. tap–tap+240 ms · j2 photo (current) · fetch 182 KB
  2. tap–tap+290 ms · Viewer and clock · preview shown
  3. tap · Viewer and clock · tap ring
  4. tap+240–tap+280 ms · j2 photo (current) · decode
  5. tap+290–tap+2,800 ms · Viewer and clock · j2 playing
  6. tap+290–tap+1,800 ms · j3 video (next) · fetch 2.25 MB
  7. tap+290–tap+520 ms · h1 photo (next person) · fetch
  8. tap+290 ms · Viewer and clock · first frame (ok)
  9. tap+290 ms · SeenTracker · j2 queued (tick)
  10. tap+520–tap+560 ms · h1 photo (next person) · decode
  11. tap+1,800–tap+2,010 ms · j4 photo (2 ahead) · fetch
  12. tap+1,800 ms · all lanes · 2.6 MB ahead (budget 4 MB) (deadline)
The current item goes first and alone; the window fills after its first frame. Times are illustrative.
Next item on screen< 100 ms when in the windowAhead on cellular≤ 1 MBRequests outside the window0
No.ConcernRiskApproachOwned by
01Current first, aloneThe window's downloads compete with the story Priya is actually waiting for.The window fills only after the current item's first frame. The current request is high priority (fetchpriority on an element, priority in fetch()), window requests low.5MediaPreloader
02Decode before showingA fetched image still stalls while it decodes on first paint.The next image is created off-screen and decode() awaited, so swapping it in is only a paint.5MediaPreloader4PlaybackClock
03Abort what the cursor leavesFast taps leave large downloads running for stories nobody sees.One AbortController per request; every cursor change aborts entries outside the window. A dropped video gets its src removed and load() called to free its buffer.5MediaPreloader
04Read the connection, assume the worstsaveData and the connection type are not available in every browser.Use them where present; otherwise the connection is unknown and gets the cellular budget.5MediaPreloader
05Preload is a hintpreload="auto" may be ignored, so a video assumed ready still stalls.Readiness comes from events (canplaythrough), not the attribute; until then the poster shows and the clock waits.5MediaPreloader
06Signed URLs ageMedia URLs expire after 30 minutes, so a viewer left open fails on the next person.Reels older than 25 minutes are refetched before their media is requested.5MediaPreloader8Stories Service
07MemoryDecoded media piles up in a long session.Keep decoded media only for the window plus the item just left; release the rest.5MediaPreloader
3

Deep dive: accessibility of auto-advancing media

Stories move on their own, which is what WCAG 2.2.2 (Level A) covers: anyone must be able to pause them so they stay paused. The viewer is also a modal dialog and must behave like one.

Keyboard map inside the viewer

KeyDoesNotes
→ / ←Next or previous storyIn right-to-left locales the pair flips (our choice), matching the tap zones
SpaceToggle the Pause buttonNot while typing in the reply box
MToggle soundRemembered for the session
EscapeClose the viewerReceipts flush; focus returns to the ring
Tab / Shift+TabMove between controlsWraps inside the dialog; the feed behind is inert
Page Down / Page UpNext or previous personMatches the neighbouring cards on desktop
Pause mechanismWCAG 2.2.2 (A)Announcements1 per personTouch targets≥ 24 × 24 px
No.ConcernRiskApproachOwned by
01A pause that staysHold-to-pause and pause-on-focus last only while held or focused, which is not a pause mechanism.A visible Pause button with aria-pressed (Space as shortcut) adds button, which only the same control removes.3StoryViewer4PlaybackClock
02Modal dialogFocus wanders into the feed behind the viewer, or is lost on close.role=dialog, aria-modal=true, aria-labelledby the author's name; the feed is inert. Focus starts on Pause and returns to the ring of the person last shown.3StoryViewer2StoryTray
03Say who, not every tickAnnouncing every story every five seconds drowns the screen reader.A polite live region speaks only on a person change ("Now viewing harbor.kitchen, 3 stories"). Within a person the media's accessible name is its alt text, and the segment group reads "Story 2 of 5 from juno.climbs, posted 3 hours ago".3StoryViewer
04Time to readFive seconds is too short for a screen reader to reach a long alt text.The Pause button stops it, and a setting turns auto-advance off so each story waits for Next.3StoryViewer4PlaybackClock
05Reduced motionThe slide between people can cause discomfort for people with vestibular disorders.Under prefers-reduced-motion reduce, people change with a short cross-fade, no slide; the segment fill stays because it is information.3StoryViewer
06Targets that existInvisible tap zones are unreachable by keyboard and switch users.Previous and Next are labelled buttons in the tab order; tap zones are a pointer shortcut over them.3StoryViewer
4

Deep dive: seen state and sync

Priya's rings need seen instantly, the author's viewer list needs it reliably: local state answers the first, the batch the second.

Ring updatesame frameReceipt delay≤ 10 s while openLost on tab close0 receipts
No.ConcernRiskApproachOwned by
01What countsCounting items the cursor merely passed turns rings grey over stories nobody saw.Seen on its first painted frame, not on fetch or cursor arrival.4PlaybackClock6Stories Store
02Optimistic ringWaiting for the server leaves a coloured ring after watching.The store adds the id to localSeen at once and derives the ring from it.6Stories Store2StoryTray
03Stale tray responseA tray refresh that left the server before the batch landed turns the ring coloured again.Ids stay in localSeen until their batch is acknowledged, and the ring treats them as seen whatever the response says.6Stories Store
04Batch and flushOne request per story is noisy; one request per session loses everything on a crash.Flush at 10 receipts, every 10 s, on close and on visibilitychange to hidden. A failed batch merges into the next.7SeenTracker
05Survive the page going awayA normal fetch started as the tab closes is cancelled with it.Every flush uses fetch keepalive with the CSRF header, under the 64 KiB limit; sendBeacon is the fallback.7SeenTracker
06Safe to repeatRetries and two open tabs send the same receipt more than once.The server inserts (viewer, item) into a set and returns 204 either way.9Stories API

The short list

Concrete decisions for the areas not picked.

No.ConcernRiskApproachOwned by
01Video: muted autoplayplay() is refused and a Pause button sits over a still picture.Videos start muted, which browsers allow. On a rejected play(), blocked joins the reasons and a Play button's tap retries inside the gesture.4PlaybackClock
02Video: poster firstA black rectangle while the first bytes arrive.The poster (or preview) paints at once in a reserved box, so nothing shifts.3StoryViewer5MediaPreloader
03Video: sound memoryUnmuting every story is tedious; unmuting forever surprises people tomorrow.The choice lives in sessionStorage, so it resets in a new tab or visit.6Stories Store
04i18n: directionIn a right-to-left locale, tapping the left side to go back feels reversed.Tap zones, arrow keys and slide follow the document's dir; the bar fills from the start edge.3StoryViewer
05i18n: relative times"3h" is meaningless or wrong in many languages.Intl.RelativeTimeFormat in the user's locale, narrow on the surface, long in the accessible name.3StoryViewer
06Security: media URLsA copied media link outlives the story or an audience change.Short-lived signed URLs; the client never builds media URLs.9Stories API10Media CDN
07Offline: clean failureA spinner forever on a dead connection.Decoded items play; the next needing the network shows Retry with the clock stopped.3StoryViewer7SeenTracker
Was this section helpful?

Trade-offs.

Six choices shaped this viewer, each with the alternatives and what they would cost.

01
How pause is modelled
Chosen:A set of reasons
  • Pro:Overlapping causes can't undo each other
  • Pro:Each reason has one owner
Downside we accept:
  • Con:Every new pause source must pick a name
Ruled out:One isPaused boolean

Releasing a hold resumes under an open reply box

Ruled out:A counter of active pauses

A missed decrement leaves it stuck; can't tell which cause is active

02
How story time is measured
Chosen:Sum of frame deltas while playing (currentTime for video)
  • Pro:Pauses and hidden tabs count nothing by construction
  • Pro:Video bar matches the picture during buffering
Downside we accept:
  • Con:Needs a frame cap so a freeze doesn't leap
Ruled out:now minus start, adjusted on pause

Every pause path must adjust the start, or the bar jumps

Ruled out:setTimeout per story

No smooth progress; pausing means recomputing the remaining time; Throttled in background tabs

03
How far ahead to preload
Chosen:A small window sized by connection
  • Pro:Instant next story in the common case
  • Pro:Bounded data use on phone plans
Downside we accept:
  • Con:A burst of fast taps can outrun it
Ruled out:The whole current reel

Several MB for stories often skipped

Ruled out:Nothing ahead

A spinner between every story

04
How seen receipts are sent
Chosen:Batched, fetch with keepalive, on hide and close
  • Pro:Few requests
  • Pro:Survives the tab closing
  • Pro:Can carry the CSRF header
Downside we accept:
  • Con:The author's list lags by up to 10 seconds
Ruled out:One request per story

Many small requests on a phone radio

Ruled out:sendBeacon only

No custom headers, so CSRF needs another scheme; No response to retry on

05
What the URL does while watching
Chosen:Push on open, replace on every move
  • Pro:Shareable and refreshable at any story
  • Pro:Back closes the viewer in one press
Downside we accept:
  • Con:A cold deep link has no feed entry behind it, so close must replace the URL with /
Ruled out:Push on every story

Leaving after twenty stories takes twenty presses

Ruled out:No URL change

Can't share or refresh a story; Back leaves the feed entirely

06
When an item counts as seen
Chosen:On its first painted frame
  • Pro:Matches what the viewer actually saw
  • Pro:Fast skips over undecoded items don't count
Downside we accept:
  • Con:A story glimpsed for 100 ms counts
Ruled out:After a minimum watch time

Rings stay coloured after quick taps through stories Priya did see

Ruled out:When the cursor arrives

Marks stories nobody saw as seen

Was this section helpful?
Next in Create
Crop and filter
Read next