Infinite photo feed.
A signed-in user scrolls a single column of photo posts. The first photo is on screen in about a second, every box keeps its size while its pixels arrive, carousels fetch only the slides someone is about to see, and coming back from a profile lands on the same post.
Builds on News feed.
Requirements exploration.
The screen is the logged-in home feed on the web: a single column of photo posts. Most of what can go wrong here is about pixels: how many bytes each photo costs, when it starts loading, whether its box moves, and how many decoded photos the tab is holding after twenty minutes. Ranking and the feed server stay a black box.
Clarifying questions
Functional requirements
Non-functional requirements
| No. | Area | Requirement | Target / measure |
|---|---|---|---|
| 01 | First photo on screen | The first post's photo is the LCP element, so it must be requested with the HTML, not after the JavaScript. | LCP ≤ 2.5 s at p75Internal target 1.8 s Mid-range phone, 4G, cold cache. |
| 02 | Layout stability | No box changes size when its photo, poster or font arrives. | CLS ≤ 0.1 (aim for 0) |
| 03 | Bytes | Each photo at the width the column needs, in the smallest format the browser decodes. | ≤ 100 KB for a typical cardNo photo wider than 1080 px |
| 04 | Interaction | Swipes, taps and scrolling stay responsive while pages and images arrive. | INP ≤ 200 msNo long task > 50 ms on append |
| 05 | Memory | A 400-post session holds about as much memory as a 40-post one. | ≤ 14 cards mountedDecoded photos only near the viewport |
| 06 | Accessibility | The list, the carousels and the videos are usable by keyboard and screen reader. | WCAG 2.2 AAAPG feed and carousel patterns |
| 07 | Return to place | Back lands on the post the reader left, even if window size or heights changed. | Same anchor post, ± 8 px |
Assumptions for the exercise
Scope
High level design.
Two data paths meet in each card. Post data goes from the Feed API through Feed Service into Feed Store as ordered ids. Pixels come straight from the Image CDN, picked by the browser from a srcset. Keeping them apart lets the first photo download before any JavaScript runs.
Component diagram
The browser half owns layout, priorities and memory; the two servers outside it are black boxes. Pick a card to light its box and its edges.
Photo feed components
The feed, state by state
Each tab is one moment in Ines's session, drawn over the same three posts. The numbered callouts name the component that owns each region.
Photo feed states
Only when Home is reached by client navigation with an empty store; a normal visit gets the first page in the HTML. Two portrait skeleton cards (the most common shape); the feed carries aria-busy="true".
Skeleton. Feed “Home”: 0 items. Footer: none. 2 skeleton items while loading.
- One post, an article in role="feed"3PostCard
- Carousel, src on the current slide and neighbours5MediaCarousel
- Sized box, placeholder, then the photo4PostMedia
- Counts read from the post by id6Feed Store
- Sentinel, loading, error and Retry2PhotoFeed
Sequence diagram
One short session: cold load, the second page, a trip to a profile and the return.
Cold load, next page and the return
- Ines → FeedRoute: open /
- Note over FeedRoute: HTML streams 12 posts + JSON; card 1 img has fetchpriority=high
- PostMedia → Image CDN: GET /m/m_7c02_0/1080.avif (found by the preload scanner)
- Image CDN → PostMedia (reply): 200 · 92 KB · immutable
- Note over PostMedia: first photo painted = LCP, before hydration
- FeedRoute → Feed Store: hydrate(page 1, next_cursor c_2)
- Ines → PhotoFeed: scroll
- Note over PhotoFeed: card 5 mounts; the browser's lazy-load distance reaches its img
- PostMedia → Image CDN: GET /m/m_7c41_0/1080.avif (lazy, width picked from srcset)
- Note over PhotoFeed: sentinel enters 200% margin
- PhotoFeed → Feed Store: loadMore()
- Feed Store → Feed Service: fetchPage(c_2) · status = loading-more
- Feed Service → Feed API: GET /api/feed?limit=12&cursor=c_2
- Feed API → Feed Service (reply): 200 · 12 posts · users · next_cursor c_3
- Feed Service → Feed Store (reply): PageLoaded (1 duplicate id dropped)
- Ines → FeedRoute: open marcos.bakes's profile
- Note over FeedRoute: save anchor { id: p_7f31, offset: 64 } with the history entry
- Ines → FeedRoute: Back
- FeedRoute → PhotoFeed: render ids, initialAnchor = p_7f31 + 64
- PhotoFeed → Ines (reply): same post, same place; photos from the HTTP cache
The carousel, up close
The same carousel post on a phone, where swiping is the main input. The general carousel pattern is taught in the Home rows topic; here the question is which slides get a src.
Carousel post on a phone
Slide 1 has a src (eager, as this is the first card). Slide 2 has one at fetchpriority="low" so a swipe never shows a blank. Slides 3 to 5 are coloured boxes with no src, so no request.
Slide 1. Feed: 1 item. 1. tidepool.studio · 3h: “Low tide at the north jetty this morning. Swipe for the crabs… more” [carousel ×5 4:5 “Slide 1 of 5, the jetty at low tide”] badges: 1 / 5. Like 1,204, Comments 38. Footer: none. Note on item:c1:media: src: slides 1, 2 · none: 3, 4, 5
- Strip, buttons and position text5MediaCarousel
- The card around it keeps one height3PostCard
Data model.
A post carries everything its box needs before a single pixel arrives: the shape, a colour, a tiny preview and a URL template that can be expanded to any width and format. Posts, users and media are stored once by id; the feed itself is only an ordered list of ids and a cursor.
From page response to store
Expanding one media item into markup
| Field in the payload | Example value | What the client does with it |
|---|---|---|
| width, height | 1080, 1350 | width and height on the img, aspect-ratio on the box; height final before any bytes. |
| url_template | https://img.example.net/m/m_7c02_0/{w}.{fmt} | Expands to one srcset per format, one entry per rung, e.g. …/640.avif 640w. |
| widths | [320, 480, 640, 828, 1080] | The rungs the CDN has, never wider than the original. A 720 px original stops at 640. |
| placeholder.color | #8a9a8f | Box background on first paint. |
| placeholder.preview | data:image/webp;base64,… | A blurred image drawn under the real one. |
| alt | Wooden jetty at low tide, wet sand and rock pools | The author's, or a generated description marked as such. Never the caption. |
Interface definition.
Three contracts: the page of posts from the Feed API, a lookup of posts by id for restoring after a reload, and the image URLs the CDN answers. Likes use the same set-style calls as the News feed topic and are not repeated here.
Transport choice
Server to client APIs
The next page of the ranked home feed. cursor is opaque and left out for the first page, which the server also embeds in the HTML. Every media item carries its shape, placeholder and URL template.
Up to 24 posts by id, in the same shape as the feed. Used after a full reload to refill the posts around a saved anchor, and to refresh one post whose media URLs have expired.
One photo at one ladder width in one format. The format is in the URL, chosen in markup by source type, so the cache key needs no Vary on Accept. Variants never change and are cached for a year.
Error cases
Page errors come back in one JSON shape that Feed Service reads. Image errors arrive as an error event on the img element, with no body to read, so PostMedia decides on its own.
| HTTP | Type | Body code | Client behaviour |
|---|---|---|---|
| 401 | challenge | auth_expired | Keep loaded posts visible, show the sign-in modal over them, and request the page again once the session is back. |
| 410 | retry | cursor_expired | Keep what is shown. Fetch a fresh first page, drop every id already in the list and append the rest; if nothing is left, show the end state. |
| 429 | retry | rate_limited+ retry_after_s | The footer counts down, then requests the page once more. Never sooner. |
| 5xx | retry | server_error | One automatic retry after 2 s with jitter, then the footer's Retry button. |
| ERR | network | offline · timeout | Pages time out at 8 s. Loaded posts and decoded photos stay; the footer shows offline and the list retries on the online event. |
| IMG ERR | retry | url_expired | Inferred, not read: the error came after the media's expires_at. Refetch the post with GET /api/posts?ids= and swap in the new template; the box never empties. |
| IMG ERR | error | image_failed | Keep the box and placeholder, show a retry glyph, and retry once when the card next comes into view. |
Client to client communication
Cards and lists dispatch to Feed Store; only the store talks to Feed Service. Image loading is the exception, because the browser does it straight from the markup PostMedia renders.
Feed Store actions
| Action | Source | Payload | Effect |
|---|---|---|---|
| feed/hydrated | FeedRoute on boot | { page } | Normalises the embedded first page. |
| feed/loadMoreRequested | PhotoFeed sentinel | {} | Ignored while loading-more or at the end; else Feed Service fetches. |
| feed/pageLoaded | Feed Service | { page } | Merges entities, appends unseen ids; end when next_cursor is null. |
| feed/pageFailed | Feed Service | { error } | Footer shows Retry or a countdown. |
| post/refreshed | Feed Service | { posts } | Replaces expired media templates. |
| card/slideChanged | MediaCarousel | { postId, index } | Keeps the index in CardUiState for remounts. |
| card/captionExpanded | PostCard | { postId } | Invalidates that id's cached height. |
| feed/anchorSaved | FeedRoute on leave | { postId, offsetPx, viewportWidth } | ScrollAnchor into history.state; snapshot of ids and cursor into sessionStorage. |
What PostMedia renders
The first card and every other card differ only in their loading hints (fetchpriority, loading, decoding, and auto in sizes), which keeps PostMedia one component with a priority prop.
<picture>
<source type="image/avif"
srcset="https://img.example.net/m/m_7c02_0/320.avif 320w,
https://img.example.net/m/m_7c02_0/640.avif 640w,
https://img.example.net/m/m_7c02_0/1080.avif 1080w"
sizes="(max-width: 500px) 100vw, 470px">
<source type="image/webp"
srcset="https://img.example.net/m/m_7c02_0/320.webp 320w,
https://img.example.net/m/m_7c02_0/640.webp 640w,
https://img.example.net/m/m_7c02_0/1080.webp 1080w"
sizes="(max-width: 500px) 100vw, 470px">
<img src="https://img.example.net/m/m_7c02_0/640.jpg"
srcset="https://img.example.net/m/m_7c02_0/640.jpg 640w,
https://img.example.net/m/m_7c02_0/1080.jpg 1080w"
sizes="(max-width: 500px) 100vw, 470px"
width="1080" height="1350"
fetchpriority="high"
alt="Wooden jetty at low tide, wet sand and rock pools">
</picture><picture>
<source type="image/avif"
srcset="https://img.example.net/m/m_7f31_0/320.avif 320w,
https://img.example.net/m/m_7f31_0/640.avif 640w,
https://img.example.net/m/m_7f31_0/1080.avif 1080w"
sizes="auto, (max-width: 500px) 100vw, 470px">
<!-- WebP source as above -->
<img src="https://img.example.net/m/m_7f31_0/640.jpg"
srcset="…"
sizes="auto, (max-width: 500px) 100vw, 470px"
width="1080" height="1080"
loading="lazy" decoding="async"
alt="A round rye loaf cut in half on a floured board">
</picture>UI component API
PhotoFeed knows nothing about photos and PostMedia nothing about feeds, so PostMedia also serves the profile grid.
PhotoFeed props
| Prop | Type | Category | Default | Why |
|---|---|---|---|---|
| ids | string[] | data | [] | Ordered post ids; never payloads. |
| renderItem | (id, meta) => node | slot | required | Renders PostCard. meta carries posInSet, setSize and isFirst, which becomes PostMedia's priority. |
| estimateHeight | (id) => number | config | from the media ratio | Height before measuring: media height from the ratio plus a fixed 168 px for the rows around it. |
| overscan | number | config | 2 | Cards mounted above and below the viewport. |
| imageBand | string | config | "100%" | rootMargin inside which cards get nearMedia = true. |
| endMargin | string | config | "200%" | rootMargin of the sentinel that calls onEndReached. |
| onEndReached | () => void | event | none | The store ignores repeats while a page is in flight. |
| initialAnchor | { id, offset } | null | data | null | Puts that post back at that offset on return, then holds it while heights settle. |
| onAnchorChange | (anchor) => void | event | none | Top visible post, once per frame, for FeedRoute to save. |
| renderFooter | (status) => node | slot | spinner · Retry · end | The footer's loading, error, offline and end states. |
PostMedia props
| Prop | Type | Category | Default | Why |
|---|---|---|---|---|
| media | Media | data | required | Shape, template, ladder, placeholder and alt. |
| sizes | string | config | "(max-width: 500px) 100vw, 470px" | The box width, so the browser can pick a rung before layout. A profile grid passes its own. |
| priority | boolean | config | false | true adds fetchpriority="high" and drops loading="lazy". Only the first card of a cold load gets it. |
| placeholder | "color" | "preview" | "none" | style | "preview" | What fills the box first. Save-Data or a tiny avatar uses color only. |
| load | "now" | "near" | "none" | config | "near" | Carousel slides beyond the neighbours pass none, which renders the box without a src. |
| onError | (mediaId) => void | event | retry once on view | Lets the card show the retry glyph or ask the store for fresh URLs after its signed URL expired. |
Children
| Child | Props | Note |
|---|---|---|
| PostCard | postId, posInSet, setSize, isFirst | An article labelled by author and time. Reads post, author and media from the store by id. |
| MediaCarousel | media[], index, onIndexChange, label | role="group" with aria-roledescription="carousel". Renders PostMedia with load now for the current slide, near for its neighbours, none for the rest. |
| FeedVideo | media, active, reducedMotion | muted, playsinline, preload="none" until near; plays only while active (60% visible) and reducedMotion is false. |
Optimizations.
On a photo feed nearly every optimisation is about images. Which bytes, when, at what priority, and for how long the decoded result stays in memory. Generic feed concerns covered by the News feed topic score low here on purpose.
Priority rubric
Each area scores 1–3 for the harm if it goes wrong, how many readers it touches and how often. The top five get a deep dive.
| Rank | Area | Harm if it fails | Readers affected | How often it matters | Score | Decision |
|---|---|---|---|---|---|---|
| 1 | Image bytes and formats | 3 of 3 | 3 of 3 | 3 of 3 | 9 | Deep divePhotos are most of the page weight on every card. |
| 2 | The first photo and LCP | 3 of 3 | 3 of 3 | 2 of 3 | 8 | Deep diveEvery cold load is judged on one image. |
| 3 | Returning to the same post | 2 of 3 | 3 of 3 | 3 of 3 | 8 | Deep diveProfile and back happens many times per session. |
| 4 | Memory over long sessions | 3 of 3 | 2 of 3 | 2 of 3 | 7 | Deep diveDecoded photos are megabytes each; a phone may discard the tab. |
| 5 | Carousel loading and accessibility | 2 of 3 | 3 of 3 | 2 of 3 | 7 | Deep diveCommon, and fetching every slide wastes most of their bytes. |
| 6 | Video in the feed | 2 of 3 | 2 of 3 | 2 of 3 | 6 | Covered elsewhereFolded into the accessibility deep dive. |
| 7 | New posts while reading | 1 of 3 | 2 of 3 | 1 of 3 | 4 | Covered elsewhereSolved in the News feed topic. |
| 8 | Offline | 1 of 3 | 1 of 3 | 1 of 3 | 3 | Covered elsewhereLoaded posts stay readable; nothing is queued. |
| 9 | SEO | 1 of 3 | 1 of 3 | 1 of 3 | 3 | Covered elsewhereSigned-in only, so crawlers never reach it. |
Deep dive: image bytes and formats
The ladder stops at 1080 px: a 470 CSS px desktop column at 2× needs about 940, and a 3× phone would ask for 1,170 and gets 1080, which nobody can tell apart in a feed. The chart shows one portrait photo at each rung.
One 4:5 photo, bytes per width rung
- JPEG
- WebP
- AVIF
Data
| Rendition width (px) | JPEG (KB) | WebP (KB) | AVIF (KB) |
|---|---|---|---|
| 320 | 22 | 15 | 11 |
| 480 | 42 | 29 | 20 |
| 640 | 68 | 47 | 33 |
| 828 | 110 | 76 | 54 |
| 1080 | 190 | 130 | 90 |
- per-card budget: Size (KB) = 100
Bytes and memory for one session
- Column width, desktop
- 470 CSS px
- Typical desktop density
- 2×
- Phone width and density
- 390 CSS px at 3×
- Posts seen in a typical session
- 60
- Photos fetched per post
- 1.3carousels add their neighbour slide
- AVIF at 1080
- 90 KB
- JPEG at 1080
- 190 KB
- Cards mounted at once
- 14
- Pixels the column needs, desktopcol × dpr940 px → 1080 rungfrom Column width, desktop and Typical desktop density
- Pixels the column needs, phonephone width × 31,170 px → capped at 1080from Phone width and density
- Photo bytes per session, AVIFposts × per-post × avif≈ 7.0 MBfrom Posts seen in a typical session, Photos fetched per post and AVIF at 1080
- The same session in JPEGposts × per-post × jpeg≈ 14.8 MBfrom Posts seen in a typical session, Photos fetched per post and JPEG at 1080
- One decoded 1080 × 1350 photo1080 × 1350 × 4 bytes (RGBA)≈ 5.8 MB
- Decoded photos for the mounted windowmounted × per-post × decoded≈ 105 MBfrom Cards mounted at once, Photos fetched per post and One decoded 1080 × 1350 photo
- Format choice halves the network cost; the ladder's cap spares 3× phones.
- On the wire a photo is under 100 KB, in memory nearly 6 MB, so the window sets the limit on a long session.
| No. | Concern | Risk | Approach | Owned by |
|---|---|---|---|---|
| 01 | Format by markup | Every browser gets JPEG, or a browser gets a format it can't decode. | picture with an AVIF source, then a WebP source, then the JPEG img. Each browser takes the first type it supports. The format is in the URL, so the CDN needs no Vary on Accept. | 4PostMedia8Image CDN |
| 02 | Width ladder, resized on the CDN | Phones download desktop photos or the full-size original, or every width gets its own cache entry. | Five fixed rungs (320 to 1080) shared by every surface and rendered once by the CDN, so hit rates stay high and the upload itself is never served. | 8Image CDN4PostMedia |
| 03 | Accurate sizes | Without sizes the browser assumes 100vw and a desktop with a 470 px column picks the 1080 rung at 1× too. | sizes="(max-width: 500px) 100vw, 470px", matching the CSS. Lazy cards prepend auto so supporting browsers use the laid-out width. | 4PostMedia |
| 04 | Save-Data | A reader on a capped plan pays for photos at full density. | Where Save-Data is reported (request header or navigator.connection.saveData), PostMedia passes sizes for 1× and skips neighbour-slide preloads. | 4PostMedia5MediaCarousel |
| 05 | Avatars | Dozens of tiny avatars each pull a large image. | A two-rung avatar template (64 and 150 px), lazy, colour placeholder only. | 3PostCard |
Deep dive: the first photo and LCP
LCP marks when the largest element in the viewport painted: here, the first card's photo. The waterfall shows one cold load three ways: the design, then two common mistakes.
First load, HTML to the first photo
Scenario 1 of 3: As described.
Timeline as a list
First load, HTML to the first photo: 6 lanes, from 0 ms to 4,000 ms.
- 0–450 ms · HTML, 12 posts · stream
- 200–550 ms · CSS · critical CSS
- 260–1,150 ms · Photo of card 1 · 1080.avif, high priority
- 320–1,500 ms · App JavaScript · download + parse
- 1,200 ms · Photo of card 1 · LCP 1.2 s (ok, ok)
- 1,500–1,750 ms · App JavaScript · hydrate
- 1,900–2,400 ms · Photo of card 2 · lazy, on first scroll
- 2,500 ms · all lanes · LCP budget 2.5 s (deadline)
| No. | Concern | Risk | Approach | Owned by |
|---|---|---|---|---|
| 01 | Photo URL in the HTML | A client-rendered card hides the URL behind the JavaScript and an API call. | The server renders the first page, including card 1's picture element, so the preload scanner finds it in the first bytes. | 1FeedRoute9Feed API |
| 02 | Priority, not lazy | Chrome starts images at low priority until layout shows they are visible; lazy delays them further. | Card 1 gets fetchpriority="high" and no loading attribute. Chrome already gives the first five large images Medium priority, so cards 2 and 3 need no hint. | 4PostMedia |
| 03 | Placeholders don't count | Expecting the blurred preview to improve LCP. | Chromium ignores low-entropy images as LCP candidates, so the stretched 24 px preview is not the LCP; the real photo is. Placeholders improve the feel, not the metric. | 4PostMedia |
| 04 | No preconnect gap | The image CDN is another origin, so its connection setup lands on the critical path. | A preconnect link to img.example.net in the head. | 1FeedRoute |
| 05 | Zero layout shift | Shifts while scrolling count towards CLS; scrolling does not excuse them. | width and height on every img, aspect-ratio on every box, slides in slide 1's shape, and captions clamped until the reader taps more (then that card is re-measured). | 4PostMedia5MediaCarousel3PostCard |
| 06 | Fade without a second paint | A photo painting half-decoded over its preview looks broken, and big decodes on the main thread delay taps. | Lazy photos use decoding="async"; PostMedia waits for img.decode() before a 150 ms fade (none with reduced motion). The first photo skips the fade so nothing delays its paint. | 4PostMedia |
Deep dive: carousel posts
A ten-slide carousel could cost ten photos to show one. Scroll snapping and focus handling come from the carousel pattern in the Home rows topic; here the questions are which slides get bytes and how the position is announced.
| No. | Concern | Risk | Approach | Owned by |
|---|---|---|---|---|
| 01 | Only neighbours get a src | loading="lazy" alone doesn't save hidden slides; since Chrome 121 horizontal scrollers use the same distance thresholds as vertical ones, so a nearby carousel fetches every slide. | MediaCarousel renders a src only on the current slide and its two neighbours; the rest are coloured boxes with no request. | 5MediaCarousel |
| 02 | Neighbours at low priority, on intent | Neighbour slides compete with the next card's main photo, and fetching slide 2 of every carousel that scrolls past wastes bytes. | Neighbours carry fetchpriority="low". Slide 2 gets its src once the card is in the image band and the pointer enters, it gets focus, or it has been fully visible for 1 s; each swipe gives the next slide its src. | 5MediaCarousel4PostMedia |
| 03 | One height for all slides | Slides of different shapes resize the card mid-swipe. | Every slide uses slide 1's aspect ratio with object-fit cover. | 5MediaCarousel9Feed API |
| 04 | Swipe, buttons and keys | Mouse users can't swipe, keyboard users can't reach slides in a scroller, and dots add tab stops. | Scroll snapping for touch; real Previous photo and Next photo buttons, shown on hover or focus, that keep focus when pressed; arrow keys when the strip has focus. Dots are hidden from assistive tech. | 5MediaCarousel |
| 05 | Structure and announcement | Slides read as a run of images with no sense of position, or every change interrupts. | The strip is role="group" with aria-roledescription="carousel" and a label ("Photos from tidepool.studio"); each slide is a group with aria-roledescription="slide" named "2 of 5". A polite live region (aria-atomic false) announces moves, as the pattern advises when nothing auto-rotates. | 5MediaCarousel |
| 06 | Index survives unmounting | A virtualised card remounts on slide 1 after the reader scrolls back. | The index lives in CardUiState by post id; the strip scrolls to it without animation on mount. | 5MediaCarousel6Feed Store |
Deep dive: memory over a long session
A post record is a few kilobytes; its decoded photo is about 5.8 MB. The design keeps loaded posts' data but only the photos of cards near the viewport.
What each loaded post holds
- posts 1–34, 34 items, In store, spacer, no img
- 35–36, 2 items, Mounted, just off screen
- 37–39, 3 items, In the viewport
- 40–41, 2 items, Mounted, just off screen
- 42–45, 4 items, In store, spacer, no img
- next page, 3 items, Not fetched yet (next page)
- anchor (top post), pointer at 36
- sentinel, cursor at 45
- 7 mounted, ≈ 9 decoded, from 34 to 41
- In store, spacer, no img
- Mounted, just off screen
- In the viewport
- Not fetched yet (next page)
As it starts. 2 steps follow.
One photo slot, from box to bitmap and back
States of4PostMedia
The states a single PostMedia goes through. The box exists in every state, so the card's height never depends on which one it is in.
| From → To | Event | Guard | Action |
|---|---|---|---|
| Box and colour, no src → src set, preview showing | card mounted or slide is a neighbour | set srcset | |
| src set, preview showing → Decoded and faded in | decode() resolves | fade in 150 ms | |
| src set, preview showing → Failed, retry glyph | img error event | ||
| Failed, retry glyph → src set, preview showing | back in view | retries < 1 | |
| Failed, retry glyph → src set, preview showing | post refreshed by id | past expires_at | |
| Decoded and faded in → Card unmounted, bytes in HTTP cache | window moved past the card | ||
| src set, preview showing → Card unmounted, bytes in HTTP cache | unmounted before load | ||
| Card unmounted, bytes in HTTP cache → src set, preview showing | card mounted again | HTTP cache hit, decode |
- Box and colour, no srcstart
- Carousel slides beyond the neighbours stay here.
- Failed, retry glypherror
| No. | Concern | Risk | Approach | Owned by |
|---|---|---|---|---|
| 01 | Window the list | After 400 posts the DOM holds thousands of nodes and hundreds of img elements, each keeping a decoded photo alive. | PhotoFeed mounts the viewport plus two cards each side (up to 14 on a tall window) and keeps spacers sized from the height cache, as in the News feed topic. | 2PhotoFeed |
| 02 | Let bitmaps go | Decoded photos far above the reader keep 5.8 MB each. | Unmounting the card removes its img, so the browser can discard the decoded data. The compressed bytes stay in the HTTP cache; scrolling back costs a decode, not a download. | 2PhotoFeed4PostMedia |
| 03 | Estimate heights from the ratio | Unmeasured cards get wrong spacer heights, so the scrollbar jumps and restoring lands off target. | Media height is exact (column width × ratio) plus a fixed allowance for the rows around it; ResizeObserver measurements replace it. | 2PhotoFeed |
| 04 | Cap the payload cache | Post data for a 2,000-post day still adds up. | Keep payloads for the 300 posts nearest the window; refetch by id if the reader scrolls back further. | 6Feed Store7Feed Service |
| 05 | Append without blocking | Normalising a page and mounting its cards in one task delays the next tap. | One store update, mount only cards inside the window, decode images asynchronously. | 6Feed Store2PhotoFeed |
| 06 | Videos release too | A paused video element keeps its decoder and buffered data. | Unmounting removes the element; the playback position stays in CardUiState for the remount. | 4PostMedia6Feed Store |
Deep dive: returning to the same post
Three ways back to the feed, each keeping a different amount of the page alive.
Three ways back to the feed
| How Ines got back | What survived | What the client does |
|---|---|---|
| Back inside the app (profile was a client route) | Feed Store in memory and the anchor in history.state | FeedRoute renders the stored ids with initialAnchor; scrollRestoration is manual, so no stale pixel offset is applied first. |
| Back after leaving the site, served from the back/forward cache | The whole page, JavaScript heap included | Nothing to rebuild. On pageshow with persisted true, reconnect the observers pagehide disconnected and resume video if it was playing. |
| Reload, or the cache missed | Only the sessionStorage snapshot (anchor, up to 200 ids, cursor, time) | If the snapshot is under 30 min old, fetch the 24 posts around the anchor with GET /api/posts?ids= and restore; otherwise start at the top with a fresh first page. |
| No. | Concern | Risk | Approach | Owned by |
|---|---|---|---|---|
| 01 | Anchor, not pixels | A pixel offset points at different content once the window width or any height above has changed. | Save the top visible post's id and its distance from the viewport top. On return, lay out from that post and correct as heights above it are measured. | 1FeedRoute2PhotoFeed |
| 02 | Take scrolling back from the browser | Automatic restoration jumps to an old offset before the list renders, then the list jumps again. | history.scrollRestoration = "manual" on the feed route; PhotoFeed alone positions the list. | 1FeedRoute |
| 03 | Stay eligible for the back/forward cache | An unload listener or open connection can keep the page out of the cache, turning an instant Back into a reload. | No unload listeners. On pagehide, disconnect observers and pause videos; on pageshow with persisted, reconnect. | 1FeedRoute |
| 04 | Photos and counts on return | Blank boxes look like a reload; counts held in memory for 25 minutes are stale. | Immutable image URLs come from the HTTP cache and placeholders cover the decode. Stale counts are accepted on an in-app Back; a reload refetches posts by id. | 4PostMedia6Feed Store |
Deep dive: accessibility, languages and video
The list follows the WAI-ARIA feed pattern as in the News feed topic, and carousel semantics are above. What remains is alt text, motion and the direction a swipe means.
| No. | Concern | Risk | Approach | Owned by |
|---|---|---|---|---|
| 01 | Alt text that describes the photo | A screen reader hears "image" forty times, or the caption read twice. | The author's alt text, or a short generated description the server labels as automatic. The caption is never copied into alt. | 4PostMedia9Feed API |
| 02 | Video and motion | Autoplaying video distracts, costs data and can trouble motion-sensitive readers. | muted and playsinline (required for inline autoplay in Safari), preload="none" until near, play at 60% visible and pause below. With prefers-reduced-motion or Save-Data, poster and play button instead. Captions on when sound is on. | 4PostMedia |
| 03 | Right-to-left languages | In Arabic or Hebrew the next slide is to the left; a hard-coded mapping sends the reader the wrong way. | Logical CSS lays the strip out right to left under dir="rtl", Next moves towards the inline end, and arrow keys follow the visual direction. | 5MediaCarousel |
| 04 | Counts, times and position text | Strings like "1,204", "3h" and "2 of 5" built by concatenation break in other languages. | Intl.NumberFormat (compact) for counts, Intl.RelativeTimeFormat for times, and plural-aware message templates for the position. | 3PostCard5MediaCarousel |
Trade-offs.
The chosen option comes first, with the alternatives and what they would have cost.
- Pro:Each browser picks a type it decodes, whatever its Accept header says
- Pro:CDN cache key is just the URL
- Con:Three srcsets per photo make the markup longer
The CDN must Vary on Accept, which splits its cache; Misses browsers that decode AVIF without listing it in Accept
About twice the bytes of AVIF on every photo
- Pro:Something meaningful paints at once
- Pro:The preview costs a few hundred bytes and no request
- Con:Adds about 0.4 KB per media item to the page response
- Con:Doesn't help LCP
Flat blocks look broken on slow connections
Needs a decoder and a canvas per card on the main thread; Nothing shows before the JavaScript runs
- Pro:Most carousels cost one or two photos
- Pro:A swipe never lands on a blank slide
- Con:A very fast double swipe can show a placeholder briefly
Horizontal scrollers use the vertical distance thresholds, so all slides of a nearby carousel load
Every swipe waits for the network
- Pro:Decoded photo memory stays flat however far the reader scrolls
- Pro:DOM size stays small
- Con:Find-in-page misses unmounted posts
- Con:Needs height estimates and a height cache
Every img stays in the DOM, so decoded photos can still pile up
Hundreds of megabytes of photos after a long session on a phone
- Pro:Lands on the same content whatever changed above it
- Pro:Works with a virtualised list
- Con:The list must hold the anchor while heights settle
Restores a pixel offset, which points at other content once heights differ
Profiles are full pages with their own URL; a modal stack gets deep
- Pro:The photo starts downloading with the first bytes
- Pro:LCP doesn't wait for JavaScript
- Con:Needs a session-aware server render of the feed
JavaScript, an API call and then the photo, one after another
- Pro:Video posts feel alive without a tap
- Pro:Only one video plays at a time
- Con:Data use while scrolling past videos
Video posts read like still photos and get skipped