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.
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
Functional requirements
Non-functional requirements
| No. | Area | Requirement | Target / measure |
|---|---|---|---|
| 01 | Responsiveness | The 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 |
| 02 | Timing | The bar never jumps or runs on while nothing is shown. | Elapsed drift < 1 frame per item0 ms counted while paused or hidden |
| 03 | Data use | Preloading 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 |
| 04 | Accessibility | Auto-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 |
| 05 | Reliability of seen state | Receipts survive tab close, retries and duplicates. | Receipts sent on hide and closeServer write idempotent |
| 06 | Smoothness | The bar animates without stealing the main thread from gestures. | 60 fps barINP < 200 ms on tap and hold |
Assumptions for the exercise
Scope
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
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
- Ring row2StoryTray
- 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.
- Tap zones and hold3StoryViewer
- Current segment4PlaybackClock
- Ready or not yet5MediaPreloader
- 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
| Reason | Added when | Removed when | Owner |
|---|---|---|---|
| hold | A press on the surface lasts longer than 200 ms | pointerup or pointercancel | StoryViewer |
| button | Pause button or Space | The same button or Space again | StoryViewer |
| reply | Focus enters the reply box | Focus leaves it (sent or dismissed) | StoryViewer |
| menu | The more menu, share sheet or report dialog opens | It closes | StoryViewer |
| hidden | visibilitychange with visibilityState hidden | visibilitychange back to visible | PlaybackClock |
| blocked | A video's play() promise rejects (autoplay refused) | The viewer taps Play, which retries play() inside the gesture | PlaybackClock |
The viewer's states
States of3StoryViewer
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.
| From → To | Event | Guard | Action |
|---|---|---|---|
| Closed (tray) → Loading item | ring tapped or deep link | pushState /stories/:u/:id | |
| Loading item → Playing | media ready | reasons empty | start clock |
| Loading item → Paused (reasons ≠ ∅) | media ready | reasons not empty | |
| Loading item → Item unavailable | 404, 410 or missing from the reel | ||
| Item unavailable → Loading item | 1.5 s or Next | itemIdx + 1 | |
| Playing → Paused (reasons ≠ ∅) | reason added | stop clock | |
| Paused (reasons ≠ ∅) → Paused (reasons ≠ ∅) | reason added or removed | set still not empty | |
| Paused (reasons ≠ ∅) → Playing | reason removed | set now empty | restart clock |
| Playing → Buffering | video waiting | ||
| Buffering → Playing | video playing | ||
| Buffering → Paused (reasons ≠ ∅) | reason added | ||
| Playing → Loading item | itemEnded | another item or person | mark seen · advance cursor |
| Playing → Loading item | tap side or arrow key | cursor ± 1 · replaceState | |
| Paused (reasons ≠ ∅) → Loading item | tap side or arrow key | only hold or button set | keep button · clear hold |
| Playing → Viewer closed | itemEnded | last item of last person | flush receipts · focus ring |
| Playing → Viewer closed | Escape, close or Back | abort window · flush receipts · focus ring | |
| Paused (reasons ≠ ∅) → Viewer closed | close | as close | |
| Buffering → Viewer closed | close | as close | |
| Loading item → Viewer closed | close | as 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
- Priya → StoryTray: tap juno.climbs ring
- StoryTray → StoriesRoute: open("juno.climbs")
- Note over StoriesRoute: pushState /stories/juno.climbs/j2 · mount dialog
- StoriesRoute → Stories Store: viewerOpened { userId }
- Stories Store → Stories Service: loadReels([juno, harbor, oskar])
- Stories Service → Stories API: GET /api/stories/reels?user_ids=u_juno,u_harbor,u_oskar
- Stories API → Stories Service (reply): 3 reels · signed URLs · seen flags
- Stories Service → Stories Store (reply): typed result · reels stored
- Stories Store → MediaPreloader: cursorChanged { juno, 1 }
- Note over MediaPreloader: fetch + decode j2 alone
- MediaPreloader → PlaybackClock (reply): j2 ready
- Note over PlaybackClock: reasons ∅ → start · elapsed 0
- PlaybackClock → Stories Store: itemShown(j2)
- Stories Store → SeenTracker: enqueue { j2, seenAt }
- PlaybackClock → Stories Store: itemEnded(j2) at 5 000 ms
- Note over Stories Store: cursor → j3 · replaceState
- Priya → StoriesRoute: Escape
- StoriesRoute → SeenTracker: flush()
- SeenTracker → Stories API: POST /api/stories/seen (keepalive)
- Stories API → SeenTracker (reply): 204
- StoriesRoute → Priya (reply): dialog closed · focus on juno ring
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
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
- photo, 1 item, Seen
- photo, 1 item, Playing
- video 12 s, 1 item, Downloading
- photo, 1 item, Not requested
- 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
- photo, 1 item, Downloading
- photo, 1 item, Not requested
- video 7 s, 1 item, Not requested
- photo, 1 item, Not requested
- photo, 1 item, Not requested
- Seen
- Playing
- Decoded and ready
- Downloading
- Not requested
- Aborted
As it starts. 4 steps follow.
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
Server to client APIs
The rings, in the server's order. The first result is embedded in the feed's HTML.
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.
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.
Sets the like; DELETE clears it. Both set state rather than toggle, so a retry is harmless.
Sends a reply as a direct message with the story attached; client_id makes a resend safe. The thread belongs to the messaging flow.
Error cases
JSON errors share one shape; MediaPreloader maps the CDN's bare statuses to the same codes.
| HTTP | Type | Body code | Client behaviour |
|---|---|---|---|
| 404 | error | item_not_found | Notice on the surface, skip after 1.5 s, remove the item from the reel. |
| 410 | error | item_expired | Same as not found; also prune items whose expires_at has passed locally. |
| 403 | retry | url_signature_expired | Refetch the reel once for fresh URLs and retry; a second 403 counts as not found. |
| 403 | error | not_permitted | Priya was removed from the audience: drop the reel and move to the next person. |
| 401 | challenge | session_expired | Close the viewer, keep receipts queued, open login with the story URL as next. |
| 429 | retry | rate_limited+ retry_after_s | Receipts only. Keep the batch and wait retry_after_s before the next flush. |
| 5xx | network | 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.
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
| Prop | Type | Kind | Why |
|---|---|---|---|
| reels | Reel[] | data | The people to play, in tray order. |
| start | { userId, itemId? } | data | Without itemId, the first unseen item. |
| imageDurationMs | number | config | Default 5 000; videos use their own length. |
| holdDelayMs | number | config | Default 200. Press length that counts as a hold. |
| preloadPolicy | (conn) => { items, bytes } | config | Window size and byte budget per connection. |
| clock | () => number | config | Defaults to performance.now; tests pass a fake. |
| onItemShown | (item) => void | event | Once per item on its first painted frame. |
| onUserChange | (userId, index) => void | event | The route replaces the URL; the live region announces. |
| onClose | (reason: 'escape' | 'button' | 'end' | 'back') => void | event | The route picks history.back or replace, and the ring to focus. |
| renderFooter | (item) => node | slot | Reply box and like by default. |
- Prop
- owner
- Default
- the current person
- Why
- Names the field and the destination thread.
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.
| Rank | Area | Impact if it fails | Viewers affected | How often it matters | Score | Decision |
|---|---|---|---|---|---|---|
| 1 | Timing and pausing | 3 of 3 | 3 of 3 | 3 of 3 | 9 | Deep diveEvery second runs on the clock; a jump or an advance under a reply is felt at once. |
| 2 | Preloading and data use | 3 of 3 | 3 of 3 | 3 of 3 | 9 | Deep diveToo much costs data plans; too little shows spinners. |
| 3 | Accessibility of auto-advancing media | 3 of 3 | 2 of 3 | 3 of 3 | 8 | Deep diveAuto-advance without a real pause fails a Level A criterion. |
| 4 | Seen state and sync | 2 of 3 | 3 of 3 | 3 of 3 | 8 | Deep diveA wrong ring colour breaks trust in the tray. |
| 5 | Video and autoplay | 2 of 3 | 2 of 3 | 2 of 3 | 6 | Short listBlocked autoplay is one more pause reason. |
| 6 | Internationalisation | 2 of 3 | 1 of 3 | 2 of 3 | 5 | Short listTap zones and arrows follow reading direction. |
| 7 | Security and privacy | 2 of 3 | 2 of 3 | 1 of 3 | 5 | Short listMostly server work. |
| 8 | Offline | 1 of 3 | 1 of 3 | 1 of 3 | 3 | Short listNo offline viewing, only a clean failure. |
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
Data
| Wall-clock time (s) | now − startedAt | Σ deltas while playing |
|---|---|---|
| 0 | 0 | 0 |
| 6 | 6 | 6 |
| 26 | 26 | 6 |
| 30 | 30 | 10 |
- 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);
};
}| No. | Concern | Risk | Approach | Owned by |
|---|---|---|---|---|
| 01 | Accumulate, don't subtract | Elapsed 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 |
| 02 | Cap one frame | A 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 |
| 03 | Video reads its own time | A 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 |
| 04 | Hidden is a reason, not a hope | Background tabs throttle timers and stop frames differently per browser. | visibilitychange adds hidden at once, so throttling never matters. | 4PlaybackClock |
| 05 | End once | A 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 |
| 06 | Paint outside React | Setting 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 |
| 07 | Hold versus tap | Every 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 |
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
| Connection | Window ahead | Video ahead | Budget |
|---|---|---|---|
| Unmetered (Wi-Fi or Ethernet, where reported) | Next 2 items and the next person's first item | Whole file, preload="auto" | ≤ 4 MB |
| Cellular or unknown | Next item and the next person's first item | Poster and preload="metadata"; bytes start when it becomes current | ≤ 1 MB |
| Data saving on (saveData true) | Next item's poster or preview only | Nothing until it is current; then it loads and plays as usual | ≤ 300 KB |
| Tab hidden or viewer closed | Nothing new; in-flight requests aborted | Nothing | 0 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.
- tap–tap+240 ms · j2 photo (current) · fetch 182 KB
- tap–tap+290 ms · Viewer and clock · preview shown
- tap · Viewer and clock · tap ring
- tap+240–tap+280 ms · j2 photo (current) · decode
- tap+290–tap+2,800 ms · Viewer and clock · j2 playing
- tap+290–tap+1,800 ms · j3 video (next) · fetch 2.25 MB
- tap+290–tap+520 ms · h1 photo (next person) · fetch
- tap+290 ms · Viewer and clock · first frame (ok)
- tap+290 ms · SeenTracker · j2 queued (tick)
- tap+520–tap+560 ms · h1 photo (next person) · decode
- tap+1,800–tap+2,010 ms · j4 photo (2 ahead) · fetch
- tap+1,800 ms · all lanes · 2.6 MB ahead (budget 4 MB) (deadline)
| No. | Concern | Risk | Approach | Owned by |
|---|---|---|---|---|
| 01 | Current first, alone | The 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 |
| 02 | Decode before showing | A 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 |
| 03 | Abort what the cursor leaves | Fast 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 |
| 04 | Read the connection, assume the worst | saveData 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 |
| 05 | Preload is a hint | preload="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 |
| 06 | Signed URLs age | Media 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 |
| 07 | Memory | Decoded media piles up in a long session. | Keep decoded media only for the window plus the item just left; release the rest. | 5MediaPreloader |
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
| Key | Does | Notes |
|---|---|---|
| → / ← | Next or previous story | In right-to-left locales the pair flips (our choice), matching the tap zones |
| Space | Toggle the Pause button | Not while typing in the reply box |
| M | Toggle sound | Remembered for the session |
| Escape | Close the viewer | Receipts flush; focus returns to the ring |
| Tab / Shift+Tab | Move between controls | Wraps inside the dialog; the feed behind is inert |
| Page Down / Page Up | Next or previous person | Matches the neighbouring cards on desktop |
| No. | Concern | Risk | Approach | Owned by |
|---|---|---|---|---|
| 01 | A pause that stays | Hold-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 |
| 02 | Modal dialog | Focus 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 |
| 03 | Say who, not every tick | Announcing 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 |
| 04 | Time to read | Five 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 |
| 05 | Reduced motion | The 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 |
| 06 | Targets that exist | Invisible 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 |
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.
| No. | Concern | Risk | Approach | Owned by |
|---|---|---|---|---|
| 01 | What counts | Counting 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 |
| 02 | Optimistic ring | Waiting 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 |
| 03 | Stale tray response | A 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 |
| 04 | Batch and flush | One 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 |
| 05 | Survive the page going away | A 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 |
| 06 | Safe to repeat | Retries 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. | Concern | Risk | Approach | Owned by |
|---|---|---|---|---|
| 01 | Video: muted autoplay | play() 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 |
| 02 | Video: poster first | A black rectangle while the first bytes arrive. | The poster (or preview) paints at once in a reserved box, so nothing shifts. | 3StoryViewer5MediaPreloader |
| 03 | Video: sound memory | Unmuting 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 |
| 04 | i18n: direction | In 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 |
| 05 | i18n: 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 |
| 06 | Security: media URLs | A copied media link outlives the story or an audience change. | Short-lived signed URLs; the client never builds media URLs. | 9Stories API10Media CDN |
| 07 | Offline: clean failure | A spinner forever on a dead connection. | Decoded items play; the next needing the network shows Retry with the clock stopped. | 3StoryViewer7SeenTracker |
Trade-offs.
Six choices shaped this viewer, each with the alternatives and what they would cost.
- Pro:Overlapping causes can't undo each other
- Pro:Each reason has one owner
- Con:Every new pause source must pick a name
Releasing a hold resumes under an open reply box
A missed decrement leaves it stuck; can't tell which cause is active
- Pro:Pauses and hidden tabs count nothing by construction
- Pro:Video bar matches the picture during buffering
- Con:Needs a frame cap so a freeze doesn't leap
Every pause path must adjust the start, or the bar jumps
No smooth progress; pausing means recomputing the remaining time; Throttled in background tabs
- Pro:Instant next story in the common case
- Pro:Bounded data use on phone plans
- Con:A burst of fast taps can outrun it
Several MB for stories often skipped
A spinner between every story
- Pro:Few requests
- Pro:Survives the tab closing
- Pro:Can carry the CSRF header
- Con:The author's list lags by up to 10 seconds
Many small requests on a phone radio
No custom headers, so CSRF needs another scheme; No response to retry on
- Pro:Shareable and refreshable at any story
- Pro:Back closes the viewer in one press
- Con:A cold deep link has no feed entry behind it, so close must replace the URL with /
Leaving after twenty stories takes twenty presses
Can't share or refresh a story; Back leaves the feed entirely
- Pro:Matches what the viewer actually saw
- Pro:Fast skips over undecoded items don't count
- Con:A story glimpsed for 100 ms counts
Rings stay coloured after quick taps through stories Priya did see
Marks stories nobody saw as seen