InstagramInfinite photo feed

100%

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.

Intermediate48 minUpdated 2 Oct 2026

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

Q1
Use casesWhat does the reader do on this screen?
Scroll the home feed, swipe through multi-photo posts, watch short videos inline, expand a caption, and open a profile or post and come back. Liking works as in the News feed topic.
Q2
Post shapesWhich kinds of post must a card handle?
A single photo, a carousel of up to 10 photos or videos, and a single video. Photos arrive in three shapes: square (1:1), portrait (4:5) and landscape (1.91:1). Every slide in a carousel shares the first slide's shape, so the card never changes height while swiping.
Q3
Image sizesDoes the server send image dimensions and a placeholder?
Yes, and that is the first thing to ask for: pixel width and height, a dominant colour and a blurred preview of a few hundred bytes per media item. Without them the box can't be reserved before the bytes arrive.
Q4
DevicesWhich devices and networks set the budget?
Desktop and mobile browsers. The budget is a mid-range Android phone on a 4G connection with a cold cache. The column is 470 CSS px wide on desktop and the full viewport width on a phone.
Q5
Session lengthHow long does a typical session run?
Assumption for the exercise: 12 posts per page, around 60 posts in a typical session and 400 in a long one. The memory budget is set for the long one, where photos, not markup, are the cost.
Q6
Coming backWhat should happen after the reader opens a profile and presses Back?
They see the same post they left, at the same place on screen, with the posts above and below still loaded. Not the top of the feed, and not a spinner.
Q7
Out of reachWhat is someone else's problem?
The stories tray, comments, ranking and the new-posts pill. Each has its own topic.

Functional requirements

#1The first post's photo, author and action bar show on first load, in ranked order.
#2More posts load before the reader reaches the end, never twice, with visible loading, failure and end states.
#3Each card shows the author row, media at its own shape, counts, and a caption clamped to two lines with more.
#4Carousel slides move by swipe, buttons and keyboard, and the current slide is always clear.
#5Videos play muted while mostly on screen; reduced motion gets a play button.
#6Back from a profile or post (including the back/forward cache) shows the same post at the same place.
#7A failed photo keeps its box and offers a retry.

Non-functional requirements

No.AreaRequirementTarget / measure
01First photo on screenThe 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.
02Layout stabilityNo box changes size when its photo, poster or font arrives.
CLS ≤ 0.1 (aim for 0)
03BytesEach 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
04InteractionSwipes, taps and scrolling stay responsive while pages and images arrive.
INP ≤ 200 msNo long task > 50 ms on append
05MemoryA 400-post session holds about as much memory as a 40-post one.
≤ 14 cards mountedDecoded photos only near the viewport
06AccessibilityThe list, the carousels and the videos are usable by keyboard and screen reader.
WCAG 2.2 AAAPG feed and carousel patterns
07Return to placeBack lands on the post the reader left, even if window size or heights changed.
Same anchor post, ± 8 px

Assumptions for the exercise

Posts per page
12
first page in the HTML
Long session
400 posts
typical is about 60
Slides per carousel
≤ 10
all the shape of slide 1
Column width
470 px
desktop; 100vw on a phone

Scope

In scope
Photo delivery (format, width ladder, sizes, bytes per card)
Placeholders and fade-in without layout shift
Priority for the first photo, lazy loading and prefetch for the rest
Carousel posts and their accessibility
Cursor paging with dedupe
Memory over a long session
Returning to the same post
Inline video in the feed
Out of scope
New-posts pill and live updatesNews feed topic
Comment threadsReactions and comments topic
The stories trayStories viewer topic
Multi-column layoutsMasonry home feed topic
Ranking and the feed serverserver black box
Was this section helpful?

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

Photo feed components. The numbered component cards that follow describe each part.
Photo feed componentsComponents: 1. FeedRoute (Route shell for /. Renders the first page from the server's HTML, saves the anchor post when the reader leaves and puts it back when they return, including after a back/forward-cache restore.), 2. PhotoFeed (The virtualised role="feed" list. Mounts the cards near the viewport, keeps measured heights by post id, asks for the next page from a sentinel and tells PostMedia which images are close enough to fetch.), 3. PostCard (One post as an article: author row, media, action bar and a caption clamped to two lines with more. Reads its post by id and never fetches.), 4. PostMedia (One photo or video in a box sized from the server's width and height. Chooses priority, lazy or eager, the srcset and sizes, paints the placeholder and fades the photo in once it is decoded.), 5. MediaCarousel (A multi-photo post. A scroll-snap strip with Previous and Next buttons, slide position text and dots. Only the current slide and its neighbours have a src.), 6. Feed Store (Posts, users and media normalised by id, the ordered list of post ids, the next cursor and the paging status. One record per post, so an avatar or like change shows on every card that uses it.), 7. Feed Service (The only module that calls the feed API. Keeps one page request in flight, times out, retries a GET once and turns responses into typed results.), 8. Image CDN (Serves each photo at the ladder's widths in AVIF, WebP or JPEG, with long-lived immutable caching. Resizing happens here, never in the browser.), 9. Feed API (Black box. Ranks posts, holds each feed session behind an opaque cursor and sends media metadata (size, placeholder, alt) with every post.).

Browser

1FeedRoute/ (home) · scrollRestoration = manual

loadMore()

ids · status

read post by id

media[] when count > 1

slide i, priority

srcset request (browser picks width)

fetchPage(cursor)

GET /api/feed

posts · users · next_cursor

hydrate · anchor

2PhotoFeed
role="feed" · ≤ 14 cards mounted
sentinel at 200% · image band 1 viewport

3PostCard
AuthorRowavatar · handle · timeActionBarlike · comment · share · saveCaptiontwo lines, then more

4PostMedia
aspect-ratio box · placeholder
picture: AVIF · WebP · JPEG
priority on card 1 only

5MediaCarousel
scroll-snap strip
src on current ± 1 only

6Feed Store
{ posts, users, media } by id
ids · next_cursor · status

7Feed Service
one page in flight · 8 s timeout
dedupe by id

9Feed API
black box · ranking
media width, height, placeholder

8Image CDN
/m/{id}/{w}.{fmt}
immutable · ladder 320–1080

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.

  1. One post, an article in role="feed"3PostCard
  2. Carousel, src on the current slide and neighbours5MediaCarousel
  3. Sized box, placeholder, then the photo4PostMedia
  4. Counts read from the post by id6Feed Store
  5. 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

Cold load, next page and the return, as an ordered list of steps:
Cold load, next page and the return20 steps between Ines, FeedRoute, PhotoFeed, PostMedia, Feed Store, Feed Service, Feed API, Image CDN. The steps are listed as text after the diagram.Image CDNFeed APIFeed ServiceFeed StorePostMediaPhotoFeedFeedRouteInesHTML streams 12 posts + JSON; card 1 img has fetchpriority=highfirst photo painted = LCP, before hydrationcard 5 mounts; the browser's lazy-load distance reaches its imgsentinel enters 200% marginsave anchor { id: p_7f31, offset: 64 } with the history entryopen /1GET /m/m_7c02_0/1080.avif (found by the preload scanner)2200 · 92 KB · immutable3hydrate(page 1, next_cursor c_2)4scroll5GET /m/m_7c41_0/1080.avif (lazy, width picked from srcset)6loadMore()7fetchPage(c_2) · status = loading-more8GET /api/feed?limit=12&cursor=c_29200 · 12 posts · users · next_cursor c_310PageLoaded (1 duplicate id dropped)11open marcos.bakes's profile12Back13render ids, initialAnchor = p_7f31 + 6414same post, same place; photos from the HTTP cache15
  1. Ines → FeedRoute: open /
  2. Note over FeedRoute: HTML streams 12 posts + JSON; card 1 img has fetchpriority=high
  3. PostMedia → Image CDN: GET /m/m_7c02_0/1080.avif (found by the preload scanner)
  4. Image CDN → PostMedia (reply): 200 · 92 KB · immutable
  5. Note over PostMedia: first photo painted = LCP, before hydration
  6. FeedRoute → Feed Store: hydrate(page 1, next_cursor c_2)
  7. Ines → PhotoFeed: scroll
  8. Note over PhotoFeed: card 5 mounts; the browser's lazy-load distance reaches its img
  9. PostMedia → Image CDN: GET /m/m_7c41_0/1080.avif (lazy, width picked from srcset)
  10. Note over PhotoFeed: sentinel enters 200% margin
  11. PhotoFeed → Feed Store: loadMore()
  12. Feed Store → Feed Service: fetchPage(c_2) · status = loading-more
  13. Feed Service → Feed API: GET /api/feed?limit=12&cursor=c_2
  14. Feed API → Feed Service (reply): 200 · 12 posts · users · next_cursor c_3
  15. Feed Service → Feed Store (reply): PageLoaded (1 duplicate id dropped)
  16. Ines → FeedRoute: open marcos.bakes's profile
  17. Note over FeedRoute: save anchor { id: p_7f31, offset: 64 } with the history entry
  18. Ines → FeedRoute: Back
  19. FeedRoute → PhotoFeed: render ids, initialAnchor = p_7f31 + 64
  20. PhotoFeed → Ines (reply): same post, same place; photos from the HTTP cache

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

  1. Strip, buttons and position text5MediaCarousel
  2. The card around it keeps one height3PostCard
Was this section helpful?

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

Column 1 is the page exactly as the API sends it. Column 2 is what Feed Store keeps, split by id. Column 3 exists only in the browser.keywordmodelfieldprimitivevalue
// 01 · GET /api/feed response
// One page, ranked.
type FeedPage = {
items: PostDTO[]
users: Record<id, User>
next_cursor?: string // null at the end
}
// Media inline, split out later.
type PostDTO = {
id: string
author_id: string
taken_at: ISO string
caption: string
media: MediaDTO[] // 1–10
like_count: int
comment_count: int
viewer_has_liked: bool
}
↓ Feed Service normalises by id
// 02 · Feed Store: entities
type Post = {
id: string
author_id: string
caption: string
media_ids: string[]
counts: { likes, comments }
viewer_has_liked: bool
}
// Enough to size and paint the box.
type Media = {
id: string
kind: MediaKind
width: int // source pixels
height: int
url_template: string // {w} and {fmt}
widths: int[] // the ladder
placeholder: Placeholder
alt: string
poster_template?: string // video only
expires_at?: timestamp // signed URLs only
}
type Placeholder = {
color: #rrggbb
preview: data URI // ~24 px wide, ≤ 400 B
}
type User = {
id: string
username: string
avatar_template: string
}
type MediaKind =
| "image" | "video"
// 02 · Feed Store: the feed
type FeedState = {
ids: string[] // ranked, deduped
next_cursor?: string
status: FeedStatus
error?: FeedError
}
type FeedStatus =
| "idle" | "loading-more"
| "error" | "end"
// 03 · Client only
// Saved per history entry.
type ScrollAnchor = {
post_id: string
offset_px: int // from viewport top
viewport_width: int
saved_at: timestamp
}
// Outlives unmounting.
type CardUiState = {
slide_index: int
caption_expanded: bool
video_time_s: number
}
// Per media id.
type MediaLoad =
| "placeholder" | "requested"
| "shown" | "released"
| "failed"

Expanding one media item into markup

Field in the payloadExample valueWhat the client does with it
width, height1080, 1350width and height on the img, aspect-ratio on the box; height final before any bytes.
url_templatehttps://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#8a9a8fBox background on first paint.
placeholder.previewdata:image/webp;base64,…A blurred image drawn under the real one.
altWooden jetty at low tide, wet sand and rock poolsThe author's, or a generated description marked as such. Never the caption.
Was this section helpful?

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

Used:HTTP JSON for pages and post lookupsGETs are safe to retry once.
Used:Plain HTTP GETs to the image CDN, from markupThe browser's loader fetches from srcset, so it can prioritise, cache and decode off the main thread. No fetch() for pixels.
Not used:Server-sent events or a WebSocketNew-post notices belong to the News feed topic.
Not used:GraphQLA fixed page shape over REST is enough for one screen.

Server to client APIs

1GET/api/feed?limit=12&cursor=c_2

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.

Request
Headers
Accept: application/json
Credentials
same-origin cookies
Response
{
"items": [
{
"id": "p_7f31",
"author_id": "u_marcos",
"taken_at": "2026-10-01T07:12:00Z",
"caption": "Third try at the rye. The crumb finally held.",
"media": [
{
"id": "m_7f31_0",
"kind": "image",
"width": 1080,
"height": 1080,
"widths": [320, 480, 640, 828, 1080],
"url_template": "https://img.example.net/m/m_7f31_0/{w}.{fmt}",
"placeholder": {
"color": "#b98b5e",
"preview": "data:image/webp;base64,UklGR…"
},
"alt": "A round rye loaf cut in half on a floured board"
}
],
"like_count": 286,
"comment_count": 12,
"viewer_has_liked": false
}
],
"users": {
"u_marcos": {
"id": "u_marcos",
"username": "marcos.bakes",
"avatar_template": "https://img.example.net/a/u_marcos/4c7a90e1d3/{w}.{fmt}"
}
},
"next_cursor": "c_3"
}
Also setsCache-Control: private, no-store
2GET/api/posts?ids=p_7f31,p_7f32,p_7f33

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.

Request
Credentials
same-origin cookies
Response200
{
"items": [
{
"id": "p_7f31",
"media": [
{
"id": "m_7f31_0",
"url_template": "https://img.example.net/m/m_7f31_0/{w}.{fmt}"
}
]
}
],
"users": {}
}
Posts the viewer can no longer see are left out, and the client drops their ids from the list.
3GEThttps://img.example.net/m/m_7f31_0/640.avif

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.

Request
The browser sends the request itself from srcset; no credentials, no JavaScript. The Accept value shown is the one MDN lists for Chrome 121 and later.
Headers
Accept: image/avif,image/webp,image/apng,image/*,*/*;q=0.8
Response
{
"content_type": "image/avif",
"content_length": 33912
}
Also setsCache-Control: public, max-age=31536000, immutable
The response is binary; the body above only describes it.

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.

{ "error": { "code": "cursor_expired", "message": "The ranked snapshot behind this cursor is gone.", "retry_after_s": null } }
HTTPTypeBody codeClient behaviour
401challenge
auth_expired
Keep loaded posts visible, show the sign-in modal over them, and request the page again once the session is back.
410retry
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.
429retry
rate_limited+ retry_after_s
The footer counts down, then requests the page once more. Never sooner.
5xxretry
server_error
One automatic retry after 2 s with jitter, then the footer's Retry button.
ERRnetwork
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 ERRretry
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 ERRerror
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

ActionSourcePayloadEffect
feed/hydratedFeedRoute on boot{ page }Normalises the embedded first page.
feed/loadMoreRequestedPhotoFeed sentinel{}Ignored while loading-more or at the end; else Feed Service fetches.
feed/pageLoadedFeed Service{ page }Merges entities, appends unseen ids; end when next_cursor is null.
feed/pageFailedFeed Service{ error }Footer shows Retry or a countdown.
post/refreshedFeed Service{ posts }Replaces expired media templates.
card/slideChangedMediaCarousel{ postId, index }Keeps the index in CardUiState for remounts.
card/captionExpandedPostCard{ postId }Invalidates that id's cached height.
feed/anchorSavedFeedRoute 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

PropTypeCategoryDefaultWhy
idsstring[]data[]Ordered post ids; never payloads.
renderItem(id, meta) => nodeslotrequiredRenders PostCard. meta carries posInSet, setSize and isFirst, which becomes PostMedia's priority.
estimateHeight(id) => numberconfigfrom the media ratioHeight before measuring: media height from the ratio plus a fixed 168 px for the rows around it.
overscannumberconfig2Cards mounted above and below the viewport.
imageBandstringconfig"100%"rootMargin inside which cards get nearMedia = true.
endMarginstringconfig"200%"rootMargin of the sentinel that calls onEndReached.
onEndReached() => voideventnoneThe store ignores repeats while a page is in flight.
initialAnchor{ id, offset } | nulldatanullPuts that post back at that offset on return, then holds it while heights settle.
onAnchorChange(anchor) => voideventnoneTop visible post, once per frame, for FeedRoute to save.
renderFooter(status) => nodeslotspinner · Retry · endThe footer's loading, error, offline and end states.

PostMedia props

PropTypeCategoryDefaultWhy
mediaMediadatarequiredShape, template, ladder, placeholder and alt.
sizesstringconfig"(max-width: 500px) 100vw, 470px"The box width, so the browser can pick a rung before layout. A profile grid passes its own.
prioritybooleanconfigfalsetrue 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) => voideventretry once on viewLets the card show the retry glyph or ask the store for fresh URLs after its signed URL expired.

Children

ChildPropsNote
PostCardpostId, posInSet, setSize, isFirstAn article labelled by author and time. Reads post, author and media from the store by id.
MediaCarouselmedia[], index, onIndexChange, labelrole="group" with aria-roledescription="carousel". Renders PostMedia with load now for the current slide, near for its neighbours, none for the rest.
FeedVideomedia, active, reducedMotionmuted, playsinline, preload="none" until near; plays only while active (60% visible) and reducedMotion is false.
Was this section helpful?

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.

RankAreaHarm if it failsReaders affectedHow often it mattersScoreDecision
1Image bytes and formats3 of 33 of 33 of 39Deep divePhotos are most of the page weight on every card.
2The first photo and LCP3 of 33 of 32 of 38Deep diveEvery cold load is judged on one image.
3Returning to the same post2 of 33 of 33 of 38Deep diveProfile and back happens many times per session.
4Memory over long sessions3 of 32 of 32 of 37Deep diveDecoded photos are megabytes each; a phone may discard the tab.
5Carousel loading and accessibility2 of 33 of 32 of 37Deep diveCommon, and fetching every slide wastes most of their bytes.
6Video in the feed2 of 32 of 32 of 36Covered elsewhereFolded into the accessibility deep dive.
7New posts while reading1 of 32 of 31 of 34Covered elsewhereSolved in the News feed topic.
8Offline1 of 31 of 31 of 33Covered elsewhereLoaded posts stay readable; nothing is queued.
9SEO1 of 31 of 31 of 33Covered elsewhereSigned-in only, so crawlers never reach it.
1

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
One 4:5 photo, bytes per width rungAt the 1080 rung AVIF needs less than half the bytes of JPEG, and every rung below 828 fits the 100 KB per-card budget in any format.050100150320480640828108022 KB42 KB68 KB110 KB190 KB15 KB29 KB47 KB76 KB130 KB11 KB20 KB33 KB54 KB90 KBper-card budgetSize (KB)Rendition width (px)One 4:5 photo, bytes per width rungAt the 1080 rung AVIF needs less than half the bytes of JPEG, and every rung below 828 fits the 100 KB per-card budget in any format.050100150320480640828108022 KB42 KB68 KB110 KB190 KB15 KB29 KB47 KB76 KB130 KB11 KB20 KB33 KB54 KB90 KBper-card budgetSize (KB)Rendition width (px)
Assumed sizes for a typical photo at good quality; real numbers depend on the photo and encoder.
Data
Rendition width (px)JPEG (KB)WebP (KB)AVIF (KB)
320221511
480422920
640684733
8281107654
108019013090
  • per-card budget: Size (KB) = 100

Bytes and memory for one session

Assumptions
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
Working
  1. Pixels the column needs, desktopcol × dpr940 px → 1080 rungfrom Column width, desktop and Typical desktop density
  2. Pixels the column needs, phonephone width × 31,170 px → capped at 1080from Phone width and density
  3. 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
  4. 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
  5. One decoded 1080 × 1350 photo1080 × 1350 × 4 bytes (RGBA)≈ 5.8 MB
  6. 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
What it means
  • 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.
Typical card≤ 100 KBWidest rendition1080 pxVariant cache life1 year, immutable
No.ConcernRiskApproachOwned by
01Format by markupEvery 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
02Width ladder, resized on the CDNPhones 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
03Accurate sizesWithout 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
04Save-DataA 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
05AvatarsDozens of tiny avatars each pull a large image.A two-rung avatar template (64 and 150 px), lazy, colour placeholder only.3PostCard
2

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.

  1. 0–450 ms · HTML, 12 posts · stream
  2. 200–550 ms · CSS · critical CSS
  3. 260–1,150 ms · Photo of card 1 · 1080.avif, high priority
  4. 320–1,500 ms · App JavaScript · download + parse
  5. 1,200 ms · Photo of card 1 · LCP 1.2 s (ok, ok)
  6. 1,500–1,750 ms · App JavaScript · hydrate
  7. 1,900–2,400 ms · Photo of card 2 · lazy, on first scroll
  8. 2,500 ms · all lanes · LCP budget 2.5 s (deadline)
Times are illustrative for a mid-range phone on 4G with a cold cache. The dashed rule is the 2.5 s LCP threshold.
LCP at p75≤ 2.5 s (aim 1.8 s)Requests before photo 1HTML onlyCLS≤ 0.1, aim 0
No.ConcernRiskApproachOwned by
01Photo URL in the HTMLA 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
02Priority, not lazyChrome 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
03Placeholders don't countExpecting 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
04No preconnect gapThe 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
05Zero layout shiftShifts 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
06Fade without a second paintA 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
Slides with a srccurrent ± 1Card height while swipingunchangedAnnouncement per moveone, polite
No.ConcernRiskApproachOwned by
01Only neighbours get a srcloading="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
02Neighbours at low priority, on intentNeighbour 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
03One height for all slidesSlides of different shapes resize the card mid-swipe.Every slide uses slide 1's aspect ratio with object-fit cover.5MediaCarousel9Feed API
04Swipe, buttons and keysMouse 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
05Structure and announcementSlides 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
06Index survives unmountingA 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
4

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

Loaded posts
  1. posts 1–34, 34 items, In store, spacer, no img
  2. 35–36, 2 items, Mounted, just off screen
  3. 37–39, 3 items, In the viewport
  4. 40–41, 2 items, Mounted, just off screen
  5. 42–45, 4 items, In store, spacer, no img
  6. 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)
Start

As it starts. 2 steps follow.

Ines's session, one segment per post. Only green and orange posts are in the DOM with an img; blue ones are spacers with cached heights.

One photo slot, from box to bitmap and back

States of4PostMedia

One photo slot, from box to bitmap and back. 5 states, 8 transitions. The table below lists them.
One photo slot, from box to bitmap and backThe states of PostMedia. 5 states, 8 transitions. The table below lists them.

card mounted or slide is a neighbour / set srcset

decode() resolves / fade in 150 ms

img error event

back in view [retries ‹ 1]
post refreshed by id [past expires_at]

window moved past the card

unmounted before load

card mounted again / HTTP cache hit, decode

Box and colour, no src

src set, preview showing

Decoded and faded in

Card unmounted, bytes in HTTP cache

Failed, retry glyph

Scroll past and back

5 steps.

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.

Transitions of One photo slot, from box to bitmap and back
From → ToEventGuardAction
Box and colour, no src → src set, preview showingcard mounted or slide is a neighbourset srcset
src set, preview showing → Decoded and faded indecode() resolvesfade in 150 ms
src set, preview showing → Failed, retry glyphimg error event
Failed, retry glyph → src set, preview showingback in viewretries < 1
Failed, retry glyph → src set, preview showingpost refreshed by idpast expires_at
Decoded and faded in → Card unmounted, bytes in HTTP cachewindow moved past the card
src set, preview showing → Card unmounted, bytes in HTTP cacheunmounted before load
Card unmounted, bytes in HTTP cache → src set, preview showingcard mounted againHTTP cache hit, decode
Box and colour, no srcstart
Carousel slides beyond the neighbours stay here.
Failed, retry glypherror
Cards mounted≤ 14Post payloads kept300 postsLong task on appendnone over 50 ms
No.ConcernRiskApproachOwned by
01Window the listAfter 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
02Let bitmaps goDecoded 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
03Estimate heights from the ratioUnmeasured 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
04Cap the payload cachePost 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
05Append without blockingNormalising 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
06Videos release tooA paused video element keeps its decoder and buffered data.Unmounting removes the element; the playback position stays in CardUiState for the remount.4PostMedia6Feed Store
5

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 backWhat survivedWhat the client does
Back inside the app (profile was a client route)Feed Store in memory and the anchor in history.stateFeedRoute 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 cacheThe whole page, JavaScript heap includedNothing to rebuild. On pageshow with persisted true, reconnect the observers pagehide disconnected and resume video if it was playing.
Reload, or the cache missedOnly 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.
Lands onthe same post ± 8 pxSnapshot lifetime30 minunload listenersnone
No.ConcernRiskApproachOwned by
01Anchor, not pixelsA 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
02Take scrolling back from the browserAutomatic 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
03Stay eligible for the back/forward cacheAn 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
04Photos and counts on returnBlank 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
6

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.

StandardWCAG 2.2 AAPhotos without alt0Autoplay with reduced motionnever
No.ConcernRiskApproachOwned by
01Alt text that describes the photoA 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
02Video and motionAutoplaying 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
03Right-to-left languagesIn 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
04Counts, times and position textStrings 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
Was this section helpful?

Trade-offs.

The chosen option comes first, with the alternatives and what they would have cost.

01
How the image format is chosen
Chosen:picture with AVIF and WebP sources, format in the URL
  • Pro:Each browser picks a type it decodes, whatever its Accept header says
  • Pro:CDN cache key is just the URL
Downside we accept:
  • Con:Three srcsets per photo make the markup longer
Ruled out:One URL, format negotiated from Accept

The CDN must Vary on Accept, which splits its cache; Misses browsers that decode AVIF without listing it in Accept

Ruled out:JPEG only

About twice the bytes of AVIF on every photo

02
What fills the box first
Chosen:Dominant colour, then a tiny blurred preview from the payload
  • Pro:Something meaningful paints at once
  • Pro:The preview costs a few hundred bytes and no request
Downside we accept:
  • Con:Adds about 0.4 KB per media item to the page response
  • Con:Doesn't help LCP
Ruled out:Dominant colour only

Flat blocks look broken on slow connections

Ruled out:A compact hash decoded in JavaScript (a blurhash-style string)

Needs a decoder and a canvas per card on the main thread; Nothing shows before the JavaScript runs

03
Which carousel slides get bytes
Chosen:Current slide and its neighbours, on intent
  • Pro:Most carousels cost one or two photos
  • Pro:A swipe never lands on a blank slide
Downside we accept:
  • Con:A very fast double swipe can show a placeholder briefly
Ruled out:Every slide with loading="lazy"

Horizontal scrollers use the vertical distance thresholds, so all slides of a nearby carousel load

Ruled out:Only the current slide

Every swipe waits for the network

04
Keeping long sessions light
Chosen:Window the list and drop img elements far from view
  • Pro:Decoded photo memory stays flat however far the reader scrolls
  • Pro:DOM size stays small
Downside we accept:
  • Con:Find-in-page misses unmounted posts
  • Con:Needs height estimates and a height cache
Ruled out:Keep every card, with content-visibility auto

Every img stays in the DOM, so decoded photos can still pile up

Ruled out:Keep everything mounted

Hundreds of megabytes of photos after a long session on a phone

05
Getting back to the post
Chosen:Anchor id plus offset, scrollRestoration manual
  • Pro:Lands on the same content whatever changed above it
  • Pro:Works with a virtualised list
Downside we accept:
  • Con:The list must hold the anchor while heights settle
Ruled out:Browser's automatic scroll restoration

Restores a pixel offset, which points at other content once heights differ

Ruled out:Open profiles in a modal over the feed

Profiles are full pages with their own URL; a modal stack gets deep

06
Rendering the first page
Chosen:Server-rendered with the first photo's markup in the HTML
  • Pro:The photo starts downloading with the first bytes
  • Pro:LCP doesn't wait for JavaScript
Downside we accept:
  • Con:Needs a session-aware server render of the feed
Ruled out:Client-rendered after the shell loads

JavaScript, an API call and then the photo, one after another

07
Video in the feed
Chosen:Muted autoplay while 60% visible, poster with reduced motion
  • Pro:Video posts feel alive without a tap
  • Pro:Only one video plays at a time
Downside we accept:
  • Con:Data use while scrolling past videos
Ruled out:Poster and play button always

Video posts read like still photos and get skipped

Was this section helpful?
Builds on this
Stories viewer
Read next