cf.completefrontend
My progress
SYSTEM DESIGN · #45

Design a news feed

An endless, server-ranked list of posts with photos, video, likes, comments, and a composer, updating live while people read. The hard parts are paging a list that changes under you, staying smooth after hundreds of posts, keeping one copy of each post across screens, instant likes that stay correct, and new posts that never move what the user is reading.

The question

Design the news feed for the web version of our social network. When people open the site, they see posts from friends, people they follow, and pages they like: text, photos, and short videos, each with like, comment, and share counts. They can like and comment right from the feed, and write their own posts with mentions and media at the top.

People scroll for a long time, often on mid-range phones. New posts are published every second, so the feed should feel live without getting in the reader's way. Walk me through how you'd build the client.

Real-life examples: Facebook News Feed, LinkedIn home feed, The home timeline on X, Instagram home feed on the web, Mastodon home timeline

Requirements: what to ask before drawing anything

A news feed sounds like one list. In an interview it's six features under one name: an endless list, a client data store, likes and comments, a composer, a live connection, and a media pipeline. You have about 45 minutes, so you can't go deep on all six. The first five minutes decide which ones matter, and you decide that by asking questions whose answers change the design.

Ask in roughly this order: who uses it and on what devices, who decides the order of posts, what a post can contain, how live it must feel, what people can do besides reading, and how long they stay. The table gives the answer this guide assumes for each, and which part of the design moves if the interviewer answers differently.

QuestionAnswer we assumeWhat it changes
Web, mobile web, or native apps?Web. About 60% of visits come from phones, many with 3 to 4 GB of memory.Slow CPUs make DOM size, JavaScript size, and memory first-class limits.
Who orders the posts?A ranking service on the server. There's also a "Latest" view in time order.The client never sorts. Pages must come from one ranked snapshot.
What can a post hold?Text up to 3,000 characters, mentions, links, up to 4 photos or one video of up to 2 minutes.Media is most of the bytes and the main cause of layout shift.
How live must it feel?New posts offered within 30 s. Counts on visible posts at most a few seconds old.Which transport (push or polling) and how new posts appear.
What can people do in the feed?Like, comment, share, and write posts with mentions and media.Optimistic writes, a composer, and file uploads.
How long do sessions last?Median 8 to 10 minutes. Heavy users load 500 posts or more.Virtualization and memory limits.
Does it need SEO?No: the feed is behind a login. First paint speed still matters.Server rendering is for speed, not for search engines.
Does it work offline?Reopening shows the last feed at once. Writing needs a connection.A small persisted cache, not a sync engine.
A wireframe of the feed page in a browser window. On the left is a navigation column with Home, Friends, and Groups. In the center, at the top, is the composer button, What's on your mind. Below it floats a pill saying 3 new posts. Below that is a post card by Asha Rao, 3 hours ago, with a media box sized before load and a row of Like, Comment, and Share buttons. Notes on the right say: the composer is lazy and loads on focus; the new posts pill floats, so nothing shifts; the media box is sized before load; likes and comments are optimistic.feed.example.comHomeFriendsGroupsWhat's on your mind?3 new postsAsha Rao, 3 hmedia box, sizedLikeCommentSharelazy composerloads on focusnew posts pillfloats, no shiftmedia box sizedbefore loadoptimistic likeand comment
Four parts, four decisions: a composer that costs nothing until used, a banner that never moves the reader, media boxes with their final size before any pixels arrive, and actions that respond in the same frame as the tap.

Functional requirements

  • Show a personalized, ranked feed that loads more posts as the user scrolls. For most users it never ends.
  • Render posts with text, mentions, links, up to four photos or one video, plus like, comment, and share counts.
  • Like and unlike a post, and add a comment, with the result showing the moment the user taps.
  • Write a post: text with @ mentions, photos or a video with upload progress, published to the top of the user's own feed.
  • Tell the user when new posts exist, without moving what they're reading.
  • Keep each post the same everywhere: the feed, the author's profile, and the post's own page show the same like count.
  • Come back to the same post after opening one and pressing Back.

Non-functional requirements, with numbers

Non-functional requirements are the qualities the feature must have, not things the user does. Give each one a number, because a number can be checked and a word like "fast" can't. These are measured at the 75th percentile of real visits, the same way Google's Core Web Vitals are.

  • Fast first view. The largest piece of content on first load, usually the first post's photo, paints within 2.5 s on a mid-range phone over 4G. This is LCP, Largest Contentful Paint.
  • Responsive. Every tap (like, open comments, expand a long post) shows its result within 200 ms. This is INP, Interaction to Next Paint.
  • Stable. Content never jumps under the reader. CLS, Cumulative Layout Shift, stays under 0.1 while images load and new posts arrive.
  • Smooth for an hour. Scrolling stays near 60 frames per second after 500 posts, and the tab stays under about 300 MB of memory.
  • Fresh. New posts are offered within 30 s. Like counts on visible posts are at most a few seconds old.
  • Accessible. Keyboard and screen reader users can move post by post, know when more posts load, and hear about new posts once, not every second.
  • Resilient. A slow or failed request never blanks the feed. The user keeps what loaded and gets a clear way to retry.

Out of scope, said out loud

Name what you won't design and why. It shows judgment, and it gives the interviewer a chance to redirect you before you spend ten minutes on the wrong thing.

  • The ranking model. It's a server and machine learning problem. The client's job is to show the order it's given and report which posts were actually seen.
  • The full comment thread with replies and sorting. We cover adding a comment from the feed; the thread is its own problem.
  • Ads, stories, and notifications. Ads are just another item type in the list, with their own impression rules.
  • Native apps. Most ideas carry over, but we design for browsers and their limits.

Check your understanding · Exercise 1

Which answers from the interviewer would change this design the most? Select all that apply.

What makes a feed hard

Before choosing anything, name the forces. Every decision later in this guide comes from one of seven properties of a feed. If you can list them in one breath, you can work out most of the design live in the interview instead of reciting it.

  1. It never ends. There's no last page for most users, so the client must keep adding posts without the page getting slower. A product page has 1 screen of content; a feed session can have 500 posts.
  2. It changes while you read it. New posts are inserted at the top every few seconds. Any paging scheme that counts from the top breaks.
  3. Someone else decides the order. A ranking service scores posts by predicted interest, and the scores change minute to minute. The client must show that order and never reshuffle it under the user's finger.
  4. It's mostly media. A page of 20 posts is about 40 KB of JSON and 2 to 4 MB of images and video. Media decides LCP, layout shift, data usage, and memory.
  5. The same data shows up in many places. One post appears in the home feed, on the author's profile, in search, and on its own page. One author appears on hundreds of posts. Copies drift apart.
  6. People act on it all the time. Likes, comments, and new posts are writes, and each must feel instant even on a 1 s round trip.
  7. Sessions are long and devices are weak. A mid-range phone has a CPU 4 to 6 times slower than a developer laptop. Small leaks that don't matter in a 2-minute test kill the tab after an hour.

Put numbers on the last one, because it surprises people. A rendered post is 150 to 400 DOM nodes: header, avatar, text, media, counts, and action buttons. Take 250 as a middle.

After 60 posts that's 15,000 nodes; after 500 posts it's 125,000. Every style recalculation and layout pass gets slower as the DOM grows, and Lighthouse already flags pages past about 1,400 nodes. Memory grows in step: the post data, the DOM, event listeners, and decoded images all add up.

How a feed grows over one session. At 10 minutes, 60 posts are loaded; at 30 minutes, 240; at 60 minutes, 500. Keeping every post in the DOM means 15,000, then 60,000, then 125,000 nodes. A virtualized list stays at about 2,500 nodes the whole time.10 min30 min60 minposts loaded60240500DOM, all kept15,00060,000125,000DOM, virtualized2,5002,5002,500about 250 DOM nodes per post
At about 250 DOM nodes per post, a feed that keeps everything grows without limit. Virtualization keeps only the posts near the screen, so an hour costs the same as a minute.

The rest of the guide takes these forces one at a time: paging and virtualization for the endless list, the normalized store for shared data, optimistic updates for constant actions, the banner and live connection for change, and media loading for the bytes. Then we put the parts together.

Think first

A teammate says: "It's just a list. Fetch the posts and render them with map." What breaks first, and roughly when?

Loading the feed: pages, cursors, and when to fetch

Start with the naive version, because it's what most people draw first: GET /feed?page=2&size=20, which the server turns into "skip 20 posts, return the next 20". This is offset pagination: the page is named by how many items come before it. It works perfectly for a list that doesn't change, like search results frozen in time.

A feed changes while it's read. Say the user loads page 1: posts p9, p8, p7, p6. While they read, three new posts arrive at the top. Now "skip 4" counts from the new top, so the next page starts at p8, and the user sees p8, p7, and p6 again.

Deletes do the opposite: if one post above is removed, one post is silently skipped. On a busy feed this happens on almost every page. There's also a server cost: databases implement a deep offset by reading and throwing away every skipped row, so page 50 is far slower than page 1.

Offset versus cursor paging. Page 1 shows p9, p8, p7, and p6. Then p12, p11, and p10 arrive at the top. Asking for offset 4 now returns p8, p7, p6, and p5: three repeats. Asking for the posts after p6 returns p5, p4, p3, and p2, with no repeats.page 1p9p8p7p6then p12, p11, p10arrive at the topoffset=4p8p7p6p53 repeatsafter p6p5p4p3p2no repeats
offset=4 counts from the top, and the top moved. A cursor counts from the last post the user saw, so inserts above it change nothing.

Decision How should the client ask for the next page of a feed that changes while it's read?

Offset and limit

The client sends ?offset=40&limit=20. The server sorts, skips 40 rows, and returns the next 20.

Pros

  • The simplest to build and to explain.
  • Can jump straight to page 7 and show a total page count.

Cons

  • Inserts above the reader cause repeats, and deletes cause skipped posts.
  • Deep offsets get slow, because the database still reads every skipped row.
  • Nobody jumps to page 7 of a feed, so its one strength is wasted.

Keyset cursor

The client sends the sort key of the last post it has: ?before=2026-10-17T09:30:00Z_p6. The server returns the next 20 posts, older than that key, using an index to seek straight to it.

Pros

  • Inserts and deletes above the reader don't affect the next page.
  • Every page is equally fast, because it's an index seek, not a skip.

Cons

  • Needs a stable, unique sort key. Time plus id works; a ranking score that changes every minute does not.
  • Can't jump to an arbitrary page.

Opaque cursor over a ranked snapshot

On the first request, the server ranks a few hundred candidate posts once and stores that ordered list for the session, for example for 30 minutes. Each response returns a nextCursor string that encodes the snapshot id and a position in it. The client treats the cursor as a black box and sends it back as-is.

Pros

  • Stable even for a ranked feed: scores can change, but this session's order doesn't.
  • The server can change what's inside the cursor at any time without a client release.
  • Works for the "Latest" view too: there the cursor just wraps a keyset.

Cons

  • The server must store snapshots, and they expire, so the client needs a plan for an expired cursor.
  • Posts published after the snapshot only show up through the new posts banner.

What we pick: An opaque cursor. For the ranked feed it's the only option that gives stable pages, and for the time-ordered view it wraps a keyset cursor, so the client code is the same for both. The client never parses or builds a cursor; it stores nextCursor from the last response and sends it back. null means there's nothing more.

Even with cursors, the client keeps three duties. It dedupes: a reshare or a post that was re-ranked across a snapshot refresh can come back twice, so ids already in the list are skipped when a page is appended. It keeps order stable: once a post is on screen it never moves, even if its like count makes it more popular.

It also reports impressions: which posts were actually seen, so the ranking service can learn. A post counts as seen when at least half of it is visible for about one second, measured with IntersectionObserver, batched every 10 seconds, and flushed with navigator.sendBeacon when the page is hidden.

When to fetch the next page

The naive trigger is a scroll listener that checks scrollTop + clientHeight against scrollHeight on every scroll event. It fires dozens of times per second, forces layout reads during scrolling, and usually fetches too late: when the user hits the bottom, they wait for the network with a spinner in view.

Instead, put an invisible sentinel element after the last post and watch it with an IntersectionObserver that has a large rootMargin, such as 1,500 px below the viewport. That's about two phone screens. The browser tells you when the sentinel comes within that distance, without any work on each scroll frame.

A user flinging at 3,000 px per second gets about half a second of head start, and a page usually arrives in 200 to 400 ms on 4G. With virtualization (next chapter), the same check runs on the index instead: fetch when the last rendered post is within about 5 posts of the end.

ts
const io = new IntersectionObserver(
  ([entry]) => {
    if (entry.isIntersecting) loadNextPage();
  },
  { rootMargin: "0px 0px 1500px 0px" },
);
io.observe(sentinel);

let inFlight: AbortController | null = null;
async function loadNextPage() {
  const feed = store.getFeed("home:top");
  if (inFlight || feed.nextCursor === null || feed.status === "error") return;
  inFlight = new AbortController();
  try {
    const page = await api.feed({ cursor: feed.nextCursor, signal: inFlight.signal });
    store.appendPage("home:top", page); // merge entities, dedupe ids
  } catch (error) {
    if (!inFlight.signal.aborted) store.setFeedError("home:top", error);
  } finally {
    inFlight = null;
  }
}
One request in flight at a time, stop at null, and an error state that waits for the user instead of retrying in a loop. Abort the request when the feed unmounts or switches tabs.
  • One request in flight. Two overlapping requests with the same cursor return the same page twice. The guard at the top of loadNextPage prevents it.
  • A clear end. When nextCursor is null, show "You're all caught up" and stop observing. An endless spinner at the bottom reads as broken.
  • Errors stop the loop. If a page fails, show a "Couldn't load more posts. Retry" row in place of the spinner. Retry automatically once or twice with backoff (1 s, then 3 s), then wait for the user. Never retry on every scroll event: a phone with no signal would fire hundreds of requests.
  • Expired cursors. If the snapshot expired (the server answers 410 Gone or a clear error code), fetch a fresh first page, dedupe it against what's loaded, and append only unseen posts. The user's position doesn't change.
Paging
Paging by offset. The server now holds 20 posts, newest first, starting p20, p19, p18, p17, p16, p15, p14, p13, p12, p11, p10, p9. You have seen 4 posts, 0 of them repeats. No page has been loaded yet.server now, newest first (20 posts)p20p19p18p17p16p15p14p13p12p11p10p9what you've seen (4)p20p19p18p17last page returnednone yetno request yetrepeats on screen: 0offset counts from the topskipped last time: 0
You've read page 1: p20 to p17. Make 3 posts arrive at the top, then load the next page.

How big is a page?

Smaller first, bigger later. The first page is on the critical path, so it should hold only what fills the first screen or two: 5 to 10 posts, rendered on the server (see the first-load chapter). Later pages can be 20 posts, about 40 KB of JSON before compression and 8 to 12 KB after.

Bigger pages mean fewer requests but more parsing and merging work in one go. Merging 50 posts into a store and rendering them can take 30 to 60 ms on a slow phone, which is a long task right while the user is scrolling.

Check your understanding · Exercise 2

The client uses opaque cursors. Which of these are still the client's job? Select all that apply.

Long sessions: virtualization and memory

The naive feed renders every loaded post and keeps it in the DOM forever. For the first few minutes that's fine. After 300 posts on a mid-range phone, it isn't: the DOM has around 75,000 nodes, a like that changes one count triggers style and layout work across a huge tree, and scrolling drops from 60 frames per second to 20 or 30.

Memory grows the same way. The user doesn't see a crash; they see a feed that gets slower the longer they use it, until the browser reloads the tab and they lose their place.

There are two different costs here, and the options below fix different ones. Rendering cost is the style, layout, and paint work for nodes the user can't see. Holding cost is the memory for nodes, listeners, and component state that still exist even if the browser skips painting them.

Decision How should the feed keep hundreds of posts without slowing down?

Render everything

Every loaded post stays mounted. Images below the screen use loading="lazy", and that's the only saving.

Pros

  • No extra code. Find-in-page, screen readers, and anchor links all work.
  • Scroll position is the browser's job, and it's always right.

Cons

  • Rendering and holding costs both grow without limit.
  • After a few hundred posts, scrolling and taps visibly slow down on phones.

content-visibility: auto

One CSS rule on each post: content-visibility: auto with contain-intrinsic-size: auto 500px. The browser skips style, layout, and paint for posts far from the viewport and uses the placeholder size instead, remembering the real size once a post has rendered.

Pros

  • Two lines of CSS, supported in all major browsers since Safari 18 in 2024.
  • Posts stay in the DOM, so find-in-page and screen readers still reach them.

Cons

  • Fixes rendering cost only. Every node, listener, and component instance still sits in memory.
  • Scripts that read layout, such as a scroll position check, can still force work on skipped posts.

Virtualize the list

Only posts near the viewport, about 5 to 10, are mounted. Posts above and below are replaced by empty spacers whose height equals the posts they stand for, so the scrollbar still matches the full list. As the user scrolls, posts entering the window mount and posts leaving it unmount.

Pros

  • Rendering and holding costs stay flat: 500 posts cost about the same as 20.
  • Mature libraries handle the hard parts, such as TanStack Virtual and react-virtuoso.

Cons

  • Find-in-page can't see unmounted posts.
  • Variable heights, scroll restore, and focus need careful handling, covered below.
  • Unmounted posts lose local state, so anything that must survive (a half-typed comment) has to live in the store.

What we pick: Virtualize, because our requirement is 500 posts at 60 fps on a 4 GB phone and only virtualization keeps the holding cost flat. Use a library that supports dynamic heights rather than writing one. If the interviewer says sessions are short (under about 100 posts), content-visibility: auto is the better trade, because it keeps find-in-page and needs almost no code.

A virtualized feed of 480 loaded posts. A spacer stands in for posts 1 to 80. Post 81 is rendered as overscan, posts 82, 83, and 84 are in the viewport, and post 85 is overscan. A spacer stands in for posts 86 to 480. Only 5 posts are in the DOM. Heights are cached by post id, and when an image above the viewport loads, scrollTop is corrected by the same amount.spacer: posts 1 to 80post 81: overscanpost 82post 83post 84post 85: overscanspacer: posts 86 to 480viewport480 posts loaded5 in the DOMheights cachedby post idimage above loads:scrollTop += 220
The spacers keep the scrollbar honest while only 5 posts exist in the DOM. When anything above the viewport grows, the list adds the same amount to scrollTop so the reader doesn't see a jump.

Posts of unknown height

Virtualizing rows of a table is easy: every row is 40 px, so row 300 starts at 12,000 px. Feed posts are hard because their height is unknown until they render. A one-line text post is about 120 px; a photo with a long caption is 900 px. The spacer above the window needs the sum of all heights above it, and those heights don't exist yet.

  1. Estimate. Start each unmeasured post with a guess. A good guess uses what you know: text length, media aspect ratio, whether there's a link preview. A flat 500 px works, but a smarter estimate means smaller corrections later.
  2. Measure. When a post mounts, a ResizeObserver reports its real height. It also reports later changes: an image loading, "See more" expanding the text, comments opening.
  3. Cache by post id, not by index. When new posts are prepended, every index shifts by the number of new posts, but post p7 is still 640 px. A height cache keyed by index would be wrong for every post at once.
  4. Correct the scroll. If a post above the viewport changes height by 220 px, everything below moves 220 px. The virtualizer adds the same 220 px to scrollTop in the same frame, so the post being read doesn't move. Browser scroll anchoring (overflow-anchor) can't be relied on here: it switches itself off when a post's top or transform changes, which is how virtualizers place posts.
  5. Overscan. Render 2 or 3 posts beyond the window in each direction, so a fast fling shows posts, not blank space, while the next ones mount.
tsx
const virtualizer = useVirtualizer({
  count: ids.length,
  getScrollElement: () => scrollRef.current,
  estimateSize: (index) => estimateHeight(posts[ids[index]]),
  getItemKey: (index) => ids[index], // heights cached by post id
  overscan: 3,
});

return virtualizer.getVirtualItems().map((item) => (
  <article
    key={item.key}
    data-index={item.index}
    ref={virtualizer.measureElement} // ResizeObserver measures it
    aria-posinset={item.index + 1}
    aria-setsize={hasMore ? -1 : ids.length}
  >
    <PostCard id={ids[item.index]} />
  </article>
));
TanStack Virtual with dynamic heights. getItemKey makes measurements follow the post when posts are prepended, and measureElement keeps them up to date.

Coming back to the same post

The user opens a post, reads the comments, and presses Back. They expect to land on the same post. Saving window.scrollY = 48,000 and restoring it fails with virtualization: heights below the first screen were estimates, so 48,000 px may now point at a different post.

Save an anchor instead: the id of the first visible post and how many pixels of it were scrolled past, like { postId: "p212", offset: 140 }. Store it in history.state with history.replaceState as the user scrolls (throttled to every 200 ms or so), set history.scrollRestoration = "manual", and on return scroll the virtualizer to that post plus the offset.

The other half is keeping the data. If the feed's ids, cursor, and entities are still in memory when the user comes back, restoring is instant. If the feed is a separate page and the browser restores it from the back/forward cache (bfcache), everything comes back for free, including scroll position. Don't block the bfcache: avoid unload listeners, and close the live connection on pagehide and reopen it on pageshow.

Memory after an hour

Virtualization caps the DOM, but other things keep growing. The store holds every loaded post with its media metadata: about 5 KB each as JavaScript objects, so 2,000 posts is 10 MB. Decoded images are bigger: a 1080 by 1350 photo takes 1080 x 1350 x 4 bytes, about 5.8 MB, once decoded for display. Browsers can free a decoded image once its img element is removed, which virtualization does for you, but only if nothing else keeps a reference, such as a cached ImageBitmap or a blob URL that was never revoked.

  • Evict post entities more than about 300 positions from the viewport that no other list references. Keep their ids and measured heights so the list's geometry is still correct, and refetch them in one batch by id if the user scrolls back.
  • Remove every listener, observer, and timer a post creates when it unmounts. A forgotten IntersectionObserver per video keeps a detached DOM tree alive.
  • Watch it in production: performance.measureUserAgentSpecificMemory() reports memory in cross-origin isolated pages in Chromium, and a simple count of loaded posts and mounted cards in your logs often finds leaks faster.
Strategy
With 200 posts loaded and the render all strategy: 200 posts are in the DOM, about 50,000 nodes. About 50,000 nodes are styled and laid out. Memory is roughly 141 MB. Find-in-page works.in the DOM50,000 nodeslaid out50,000 nodesmemory141 MBfind-in-pageworksloaded: 200 posts, mounted: 200render all:Under the memory budget, but every frame lays out a huge tree.
Under the memory budget, but every frame lays out a huge tree. A rough model: 250 nodes per post, 0.3 MB per mounted post, 5 KB of data per loaded post, on top of an 80 MB page.

Check your understanding · Exercise 3

A virtualized feed jumps by a few hundred pixels whenever new posts are prepended at the top. What are likely causes? Select all that apply.

One copy of every post: the client store

The naive client stores what each screen fetched, as it came back: the home feed's response holds 20 posts, the profile page's response holds 15, the post page holds 1. Data-fetching libraries such as TanStack Query and SWR work this way by default: each query (a request and its key) has its own cached copy of the response.

Now follow one like. The user likes post p7 in the home feed, then opens the author's profile, loaded 5 minutes ago and still in the cache. It shows p7 with the old count and an empty heart.

They open p7's page and add a comment; back in the feed, the comment count is one short. Each screen is right about its own copy, and the copies disagree. Authors make it worse: if Asha changes her photo, her old photo stays on every cached post she wrote.

Decision How should the client store posts, authors, and comments that appear on many screens?

A cache per query

Each request's response is cached under its key, like ["feed", "home"] or ["profile", "u2"]. To update a post, you find and patch every cached response that contains it.

Pros

  • The least code. Caching, request dedupe, refetch on focus, and retries come with the library.
  • Easy to reason about one screen at a time.

Cons

  • Every write has to patch every query that might contain the entity, and it's easy to miss one.
  • Pushed updates (live counts) face the same search across all queries.

A normalized entity store

Every entity is stored once, in a table keyed by id: posts["p7"], users["u2"], comments["c91"]. A post holds authorId, not a copy of the author. Lists, such as the home feed or a profile, hold only ids in order.

Pros

  • One write updates every screen. A like on p7 is one change to one row.
  • Live updates from the server find their target in one lookup.

Cons

  • More code: responses must be split into tables, and screens must join them back.
  • Entities no list uses anymore pile up and need cleaning.

Both: a query layer for lists, a normalized table for entities

The data-fetching library still owns loading states, retries, and cursors per list. But when a response arrives, its posts, users, and comments are merged into normalized tables, and the query keeps only the list of ids.

Pros

  • Keeps the library's request handling and adds one copy per entity.
  • Relay and Apollo Client do this automatically for GraphQL; with REST it's a small merge function.

Cons

  • Two places to look when debugging.
  • Needs a clear rule for merging an older or partial copy into a newer one.

What we pick: A normalized store, either written directly (Redux Toolkit's entity adapter, Zustand, or a small custom store) or as the third option on top of a query library. The feed has every reason to normalize: the same post appears in several lists, authors repeat on hundreds of posts, and live updates and optimistic writes need to find an entity by id fast. Per-query caches are fine for screens whose data appears nowhere else, like settings.

A normalized store. Three lists hold only ids: the home:top feed holds p9, p7, and p3; the profile list for user u2 holds p5 and p7; the post page holds p7. All three p7 entries point to one row in the posts table, p7 with 1,205 likes. That row points to user u2, Asha, in the users table, and to comment c88 in the comments table. The posts table also holds p9, p5, and p3.lists hold idshome:topp9p7p3profile:u2p5p7post:p7p7postsp9: 12 likesp7: 1,205 likesp5: 3 likesp3: 88 likesusersu2: Ashau7: Bencommentsc88: Wowlike on p7: one writeall 3 lists show 1,205
Lists hold ids, not posts. A like on p7 changes one row, and the feed, the profile, and the post page all show 1,205 the next time they render.

To make normalization cheap, ask the API to return data already split up: an ordered list of post ids plus posts and users arrays (see the interface chapter). The client merges arrays into tables without walking nested objects. GraphQL clients get the same effect from ids and __typename on every object.

The merge rule: newer wins, partial never erases

Three sources write posts into the store: pages from the feed loader, pushed count updates from the live connection, and responses to the user's own likes and comments. They arrive in any order. A page requested before a like but delivered after it would roll the count back if the store just replaced the post.

The fix is a version on every server copy: an updatedAt timestamp or, better, a version number that goes up on every change. The store keeps a field only if the incoming copy is not older than what it already has.

Partial copies need a second rule. The feed returns a post with a shortened preview and the first 2 comments; the post page returns the full text and 50 comments. If the user opens the post page and comes back, the feed's next refresh must not replace 50 comments with 2. Merge field by field, and track how complete an entity is, for example detail: "summary" | "full", so a summary never overwrites fields only the full copy has.

ts
function mergePost(current: Post | undefined, incoming: Post): Post {
  if (!current) return incoming;
  if (incoming.version < current.version) return current; // stale copy: ignore
  return {
    ...current,
    ...incoming,
    // a summary copy never erases fields only the full copy has
    text: incoming.detail === "summary" && current.detail === "full" ? current.text : incoming.text,
    detail: current.detail === "full" ? "full" : incoming.detail,
  };
}
One merge function used by every writer: pages, pushes, and mutation responses. Older versions are ignored and summaries don't overwrite full posts.

Each card subscribes to its own post

Normalization only pays off if a change to p7 re-renders only p7's card. If the whole FeedList reads the whole posts table, one like re-renders every mounted card. With virtualization that's 10 cards, not 500, but each card is a large component and 10 of them can still cost 20 to 40 ms on a slow phone. That's a big part of the 200 ms INP budget gone on work nobody sees.

So the list passes ids only, and each PostCard subscribes with a selector: usePost(id) returns posts[id], and the card re-renders only when that object changes. Zustand, Redux, and React's own useSyncExternalStore all support this. Inside the card, split further: the like bar subscribes to the like count, so a pushed count re-renders a 20-node bar, not a 300-node card with a video in it.

Check your understanding · Exercise 4

The store is normalized. Which of these does that make easy? Select all that apply.

Likes and comments that feel instant

The naive like button sends the request and waits. On good 4G that's 150 to 300 ms; on a train it's 1 to 3 s. The user taps, nothing happens, and they tap again, which unlikes the post.

A spinner on the heart passes INP (something painted quickly), but it doesn't feel like a like. People expect the heart to fill in the same frame as the tap.

The fix is an optimistic update: change the store as if the server already agreed, send the request, and repair the store if the server says no. Likes fail rarely (well under 1% of the time, mostly from lost connections), so being optimistic is right almost always. The hard part isn't the happy path. It's what happens when the user taps fast, the network reorders requests, or a request fails after more taps.

Decision How should a like be sent so it feels instant and the server ends up agreeing with the screen?

Wait for the server

Disable the heart, send the request, and update the count when the response arrives.

Pros

  • Never shows something false.
  • No rollback code.

Cons

  • Every like feels slow, and on bad networks the button looks broken.
  • Disabled buttons drop the user's second tap, which feels like a bug.

Optimistic, one request per tap

Each tap flips the heart and sends its own request (POST /toggle or a PUT/DELETE). On failure, undo that tap.

Pros

  • Instant feedback.
  • Simple to write.

Cons

  • Two taps put two requests on the network. They can arrive in either order, so the server may store "liked" while the screen says "not liked".
  • "Undo that tap" is wrong when other taps happened since: it can flip the heart to a state nobody asked for.

Optimistic, with an intent per post

The store keeps, per post, the state the user last asked for (desired) and the state the server last confirmed. At most one request per post is in flight. Taps only change desired. When the request returns, if desired differs from what was just confirmed, send one more.

Pros

  • Instant feedback, and the last tap always wins on the server.
  • Five quick taps cost at most two requests.
  • Rollback has a clear target: the last confirmed state.

Cons

  • More state per post, and it must live in the store, not the button, so every screen agrees.
  • A little more code to test.

What we pick: The intent per post. It's the only option that is both instant and correct under fast taps and reordered responses. Send the desired state, never a toggle: PUT /posts/p7/like means liked and DELETE /posts/p7/like means not liked. Both are idempotent, meaning sending one twice has the same effect as once, so a retry after a timeout can't flip the like back.

The rapid-tap race, step by step

Follow the naive version through one bad case. At 0 ms the user taps like: the heart fills and PUT #1 leaves. At 120 ms they tap again to unlike: the heart empties and DELETE #2 leaves. #2 takes a fast path and arrives first; #1 hits a slow server and arrives last.

The server applies DELETE, then PUT, and stores "liked". The screen says "not liked". Nothing on the client knows they disagree until the next refresh, when the heart fills by itself.

With an intent per post: at 0 ms desired becomes liked and PUT #1 leaves. At 120 ms desired becomes not liked, but a request is in flight, so nothing is sent. When #1 returns, the store records "confirmed: liked", sees desired is not liked, and sends DELETE #2.

Requests for one post can't overtake each other, because only one exists at a time. Try both strategies in the lab below: tap twice, then deliver the newest request first.

Requests
Like lab, every tap sends a request. The screen shows not liked with 1204 likes. 0 requests in flight. The server stores not liked. 0 requests sent. Status: screen and server agree.screenlike: 1,204in flightnoneservernot likedmode: every taprequests sent: 0screen and server agreeconfirmed: not liked
Tap like twice, then deliver the newest request first. Watch what the server stores.
ts
function tapLike(postId: string) {
  const intent = store.intent(postId);
  intent.desired = !shownLiked(postId); // the heart flips at once
  if (!intent.inFlight) flush(postId);
}

async function flush(postId: string) {
  const intent = store.intent(postId);
  const post = store.post(postId);
  if (intent.desired === post.viewerLiked) return; // already agrees
  intent.inFlight = true;
  const want = intent.desired;
  try {
    const result = await (want ? api.like(postId) : api.unlike(postId));
    store.mergePost(postId, result); // confirmed: viewerLiked, likeCount, version
  } catch {
    intent.desired = post.viewerLiked; // back to the last confirmed state
    toast("Couldn't update the like. Try again.");
  } finally {
    intent.inFlight = false;
  }
  if (intent.desired !== store.post(postId).viewerLiked) flush(postId); // a later tap
}
One request in flight per post, the desired state sent each time, and rollback to the last state the server confirmed.

What does the heart show while a request is in flight? The server's count, corrected by the pending intent: if the user wants "liked" and the server last confirmed "not liked", show likeCount + 1. Keeping the server's facts and the user's intent in separate fields matters when live updates arrive.

If a push says p7 now has 1,210 likes, it replaces the server count, and the correction still adds the user's pending like on top, once. Once the like is confirmed, viewerLiked is true and the correction disappears, so it's never counted twice.

Optimistic comments

Comments are harder than likes because they create something new, and the new thing has no server id yet. When the user submits "Congrats!", the client creates a comment with a temporary id like tmp_8f2, status sending, the user as author, and the current time. It appears under the post at once, slightly faded with "Sending...". The request carries an idempotency key, a random id the client makes once per comment (often sent as an Idempotency-Key header), so a retry after a timeout can't post the comment twice.

On success, the server returns the real comment with id c91. The store replaces tmp_8f2 with c91 everywhere it appears (in the post's comment id list) and bumps the post's comment count from the response, not by adding 1 again. On failure, the comment stays, marked "Not posted. Retry", with the text kept.

Throwing away what someone typed is the worst failure in a composer. If the user deletes the failed comment, it's simply removed from the store; nothing was ever on the server.

The states of an optimistic comment. From draft, submit moves it to sending, shown faded with the temporary id tmp_8f2. A 201 response swaps in the real id c91 and it becomes posted. An error moves it to failed, which shows Not posted, Retry, with the text kept. Retry sends it again with the same idempotency key, back to sending. Delete from failed removes it; it was never on the server.drafttext keptsendingtmp_8f2, fadedpostedid c91failedNot posted. Retryremovednever on serversubmit201: swap iderrorretry, same keydeleteIdempotency-Keysame every try
The comment is on screen from the first moment, and the text survives every failure. The idempotency key stays the same across retries, so a lost response can never post the comment twice.

Check your understanding · Exercise 5

A user taps like on a post 4 times quickly (like, unlike, like, unlike) and the first request fails. With an intent per post, what should happen? Select all that apply.

The composer: text, mentions, and media

The naive composer is a textarea, a file input, and a Post button that sends one multipart form with the text and all the files. Picture the user who writes three paragraphs, attaches a 40 MB video, and presses Post on a train. The upload starts only now, takes two minutes, and fails at 80% when the train enters a tunnel.

The whole form failed, so they start over, and if the page reloaded, their text is gone too. The composer is where users invest the most effort, so it's where losing work hurts the most.

Split the composer into three jobs that can each fail and recover on their own: editing text with mentions, uploading media as soon as it's picked, and publishing the post once both are ready. The collapsed composer at the top of the feed ("What's on your mind?") is just a button. The editor's JavaScript loads when the user focuses or clicks it, so the 50 to 100 KB of editor code is never on the feed's critical path.

Mentions: a link to a person, not an at sign in text

When the user types @as, the composer shows matching people. Make the first results instant by loading the user's top 50 or so frequent contacts when the composer opens, and filter them locally on each keystroke. For everyone else, query GET /mentions/suggest?q=as after about 150 ms without typing, cancel the previous request with an AbortController so a slow answer for @a can't replace the answer for @as, and cache results by query. The suggestion popup follows the ARIA combobox pattern: focus stays in the editor, the arrow keys move aria-activedescendant through a listbox, Enter picks, and Escape closes.

When a person is picked, the composer stores the mention as structured data, not just text. The post body is plain text plus a list of entities, ranges that say "characters 7 to 15 are a mention of user u2". Storing only @Asha Rao as text fails in several ways: display names have spaces and aren't unique, people rename themselves, the server needs the id to send a notification, and the client needs it to draw a link. Ranges also make rendering safe: the client builds text nodes and links from the ranges and never uses innerHTML, so a post containing script tags is just text.

The post text Thanks Asha Rao for the tips on the trail map, split into pieces. Asha Rao, from character 7 to 15, is a mention entity pointing to user u2. Trail map, from character 36 to 45, is a link entity with a URL. Notes say the mention renders as a link to Asha's profile, notifies u2 by id, and still points at u2 if she renames herself.what the user typedThanksAsha Raofor the tips on thetrail map!7153645mentionuserId u2, 7 to 15linkurl, 36 to 45renders as alink to /u/ashanotifies u2by idrename: stillpoints at u2
The text stays plain, and the ranges say which characters mean what. The client builds text nodes and links from the ranges, so nothing in a post is ever parsed as HTML.
json
{
  "text": "Thanks Asha Rao for the tips on the trail map!",
  "entities": [
    { "type": "mention", "userId": "u2", "start": 7, "end": 15 },
    { "type": "link", "url": "https://example.com/map", "start": 36, "end": 45 }
  ],
  "mediaIds": ["m_41", "m_42"],
  "audience": "friends"
}
Text plus ranges. Agree on the unit of start and end with the server: JavaScript strings count UTF-16 code units, so an emoji counts as 2, while many server languages count code points.

Which editor? A plain textarea can't draw a mention as a highlighted chip, and hand-written contenteditable code is famously hard to get right across browsers, input methods, and spellcheckers. Use an editor framework built for it, such as Lexical (from Meta) or ProseMirror, which keep their own document model and map it to entities. When the user deletes into a mention chip, one Backspace should remove the whole mention, or turn it back into plain text, not leave half a name linked to the wrong person.

Decision When and where should photos and videos be uploaded?

With the post, on submit

Files wait in the browser until the user presses Post, then go to POST /posts as one multipart request with the text.

Pros

  • One request, one transaction. No orphan files on the server.
  • The simplest server code.

Cons

  • The upload time starts when the user is done, so Post can take minutes.
  • Any failure loses everything, and a retry sends every byte again.
  • Large bodies through the API servers tie them up for minutes.

On select, through our API server

Each file is sent to POST /uploads the moment it's picked. The API server receives it and writes it to storage. The post later refers to the upload ids.

Pros

  • Uploads overlap with the user writing, so Post is usually instant.
  • Each file can fail and retry alone.

Cons

  • Every byte passes through the API servers, which are built for small JSON requests, not gigabytes of video.
  • Files the user removes or never posts must be cleaned up.

On select, straight to storage with a signed URL

When a file is picked, the client asks the API for an upload slot: POST /uploads with the file's type and size. The API checks the limits and returns a short-lived signed URL for object storage (S3, Google Cloud Storage, or similar). The client sends the file straight there, then tells the API it's done. Large videos go in chunks that can resume.

Pros

  • Uploads overlap with writing, and the API servers never touch the bytes.
  • Storage services are built for large uploads, chunking, and resume.
  • Each file has its own progress, failure, and retry.

Cons

  • Three steps instead of one, and more states to show.
  • Unused uploads still need cleanup, usually a storage rule that deletes unclaimed files after 24 hours.

What we pick: Upload on select, straight to storage with a signed URL. The upload runs while the user is still writing, so most posts publish instantly, and a failed file can be retried alone without touching the text. The API keeps control because it decides who gets a signed URL, for which type, and up to which size.

Uploading as a sequence between the composer, the API, and object storage. The composer asks the API for an upload slot with the file's type and size. The API replies with a signed URL. The composer sends the file straight to storage with a PUT, showing progress from 0 to 100 percent. The composer tells the API the upload is complete. The API replies processing, then ready. Finally the composer posts the text with the media ids to the API.ComposerAPIStoragePOST /uploads (type, size)signed URLPUT file, 0 to 100%upload completeprocessing, then readyPOST /posts with mediaIds
The API decides who may upload what, but never touches the bytes. The upload runs while the user is still writing, so by the time they press Post, the media is usually ready.

Progress, size, and resume

  • Progress needs XMLHttpRequest. fetch has no upload progress events, but xhr.upload.onprogress reports bytes sent and the total. Show a bar per file with role="progressbar" (or a progress element) and a Cancel button that calls xhr.abort().
  • Shrink photos first. A 12-megapixel phone photo is 3 to 5 MB. Decoding it with createImageBitmap, drawing it at 2,048 px on the long side, and exporting with canvas.toBlob(cb, "image/jpeg", 0.85) gives about 400 to 600 KB: a 5 to 10 times faster upload. Re-encoding also drops metadata such as GPS location, though the server should strip it too.
  • Show a preview at once. URL.createObjectURL(file) gives a local URL the img or video can show before any byte is uploaded. Call URL.revokeObjectURL when the file is removed, or the memory is never freed.
  • Chunk large videos. Send video in parts of 5 to 10 MB using the storage's multipart upload or the open tus protocol. When the network drops, ask which parts arrived and continue from there. Losing a train tunnel's worth of progress costs one chunk, not the whole file.
  • Processing is a state too. After upload, the server makes thumbnails and transcodes video, which can take seconds to minutes. The upload slot goes from uploading to processing to ready (or failed), and the client learns of it from the live connection or by polling GET /uploads/:id every few seconds.
ts
function uploadFile(signedUrl: string, file: Blob, onProgress: (pct: number) => void) {
  const xhr = new XMLHttpRequest();
  const done = new Promise<void>((resolve, reject) => {
    xhr.upload.onprogress = (e) => {
      if (e.lengthComputable) onProgress(Math.round((e.loaded / e.total) * 100));
    };
    xhr.onload = () => (xhr.status < 300 ? resolve() : reject(new Error(`HTTP ${xhr.status}`)));
    xhr.onerror = () => reject(new Error("network"));
    xhr.onabort = () => reject(new DOMException("cancelled", "AbortError"));
  });
  xhr.open("PUT", signedUrl);
  xhr.setRequestHeader("Content-Type", file.type);
  xhr.send(file);
  return { done, cancel: () => xhr.abort() };
}
One file, straight to its signed URL, with progress and cancel. The composer runs one of these per attachment, at most 2 or 3 at a time.

Publishing and drafts

Let the user press Post even while uploads are still running. The post is queued and shows as "Posting when your video finishes" at the top of their own feed, instead of a disabled button they have to wait on. When every upload is ready or at least uploaded, the client sends POST /posts with the text, entities, media ids, and an idempotency key, so a retried request never makes two posts.

The new post is inserted at the top of the user's feed right away, marked as sending, exactly like an optimistic comment. If the server needs time to process a video, the post shows "Processing video" until a live update says it's ready.

Save the draft as the user types: text, entities, and upload ids, debounced to every 500 ms or so, in localStorage (small) or IndexedDB (if you keep local file blobs). On reopen, offer to restore it. While an upload is running or a draft is unsaved, a beforeunload handler asks the browser to warn before leaving. Clear the draft only when the server confirms the post.

Check your understanding · Exercise 6

Which of these make the composer safer for a user on a bad connection? Select all that apply.

New posts while reading

Two kinds of change arrive while the user reads. New posts belong above everything they've seen. New numbers, like counts and comment totals, belong on posts already on screen. They need different handling, and the first decision isn't about the network at all: it's what the user should see when a new post exists.

The naive answer is to insert new posts at the top as they arrive. Try it with a user reading post 40. Three posts arrive above.

If the list doesn't correct the scroll, everything moves down by the height of three posts, about 1,500 px, and the post they were reading is gone from the screen. If it does correct the scroll, the user sees nothing, but they also never find out new posts exist. Either way, auto-inserting while someone reads is the wrong design.

Decision What should the user see when new posts arrive while they read?

Insert them at once

Prepend new posts to the list as soon as they arrive.

Pros

  • Always up to date.
  • No extra UI.

Cons

  • Without scroll correction, the reader's post jumps away. With it, the new posts are invisible above them.
  • Fetches every new post for every user, even users who'll never scroll up.

A "New posts" banner

Keep a count of new posts. Show a small pill at the top of the viewport: "3 new posts". On click, scroll to the top and insert them.

Pros

  • Nothing moves until the user asks.
  • Posts are fetched only on click, so most users never download them.

Cons

  • The user at the very top has to click for something that could just appear.
  • One more piece of UI that must be accessible.

Banner, or insert when at the top

If the user is within about 100 px of the top, insert new posts directly. Anywhere else, show the banner.

Pros

  • The reader is never moved, and the user at the top sees new posts appear with no click.
  • Same code paths as the banner.

Cons

  • Two behaviors to explain and test.
  • An insert at the top must still respect reduced motion.

What we pick: The banner, with direct insert when the user is at the top. Nothing moves under a reader, and posts are only downloaded for users who want them. The user's own new post is the exception: it's inserted at the top at once, because they just made it and expect to see it.

New posts
New posts shown by insert. The viewport shows p41, p40, p39, p38. The post being read, p40, is still in view, with 10 posts above it.viewportp41p40, readingp39p38mode: insertwaiting behind banner: 0p40: still in viewposts above it: 10position: mid-feedposts in the list: 50
You're reading p40, mid-feed. Make 3 new posts arrive.

The banner has its own details. Cap the label ("20+ new posts"), and if more than about 50 posts are waiting, reload the first page instead of prepending, because the user has effectively been away. On click, scroll to the top (smoothly, unless the user prefers reduced motion), fetch the new posts, dedupe them against loaded ids, prepend them, and move focus to the first new post so keyboard and screen reader users land in the right place. Announce the banner once through a polite live region when it first appears, not on every count change.

How the browser hears about changes

Decision How should the server tell the client about new posts and new counts?

Short polling

Every 30 s, the client asks GET /feed/new-count?since=....

Pros

  • Plain HTTP requests: easy to cache, load balance, and debug.
  • Fine for the banner alone, where 30 s is fresh enough.

Cons

  • Counts on visible posts would need much faster polls.
  • With 10 million open tabs, a 30 s poll is about 330,000 requests per second, nearly all answering "nothing new".

Long polling

The client sends a request the server holds open until there's news or 30 s pass. Then the client immediately sends the next one.

Pros

  • Works through any proxy or firewall.
  • Near-instant delivery.

Cons

  • A new request and response headers for every batch of events.
  • Reconnect, ordering, and resume are all hand-written.

Server-sent events (SSE)

One long-lived HTTP response that the server keeps writing small text events to, read in the browser with EventSource. Each event can have an id; after a drop, the browser reconnects by itself and sends Last-Event-ID, so the server can resume.

Pros

  • Exactly the shape of the problem: one-way, small, text.
  • Reconnect and resume are built into the browser.
  • Over HTTP/2 it shares one connection with every other request.

Cons

  • One-way only. Client messages (like "I'm watching these posts") go as normal requests.
  • Over HTTP/1.1, each open stream uses one of the browser's 6 connections per site, shared by all tabs.

WebSocket

A two-way connection opened with new WebSocket(url), carrying messages in both directions.

Pros

  • Two-way and low overhead per message.
  • The obvious choice if the app already keeps one open for chat or presence.

Cons

  • More than a feed needs. Reconnect, backoff, resume, and heartbeats are all yours to write.
  • Some corporate proxies still break long-lived WebSocket connections.

What we pick: Server-sent events, sent over HTTP/2, unless the app already keeps a WebSocket open for messaging, in which case add feed events to that one connection rather than opening a second. Either way, the events are hints, not data: new-posts says how many, and post-stats carries a few numbers. Short polling every 30 to 60 s is the fallback when the stream can't connect.

The new posts banner as a sequence between the server, the store, the feed, and the user. The server sends a new-posts event with count 3 to the store. The user is mid-feed, so the store buffers the hint and the feed shows a pill. The feed shows the user a pill saying 3 new posts. The user clicks the pill. The feed scrolls to the top and asks the server for the new posts. The server replies to the store with 3 posts, which are deduped and prepended.ServerStoreFeedUsernew-posts: 3mid-feed: bufferpill: 3 new postsclicks the pillGET /api/feed/new3 posts, prepended
The stream carries a count, not posts. Nothing above the reader changes until they click, and posts are downloaded only for users who ask for them.

Counts: only for posts in view, at most once a second

A viral post can gain 50 likes a second. Applying each one as it arrives re-renders the like bar 50 times a second, for a number no one can read that fast, while the user is scrolling. Two limits fix it.

First, the client tells the server which posts are on screen with POST /feed/watch, throttled to once every 2 seconds, so the server sends counts only for those. Second, the client buffers incoming post-stats events and applies them once a second in a single store update. If 50 updates for p7 arrive in that second, only the newest one, by version, is applied.

Reconnects, background tabs, and gaps

  • Know when EventSource gives up. It reconnects by itself after network drops, but if the server answers with an error status such as 502, the browser closes the stream for good (readyState becomes CLOSED). Watch for that and reconnect yourself with exponential backoff and random jitter (1 s, 2 s, 4 s, up to 60 s), so a server restart isn't hit by every client in the same second.
  • Send heartbeats. The server writes a comment line every 15 to 30 s. Proxies that close idle connections see traffic, and the client can treat 60 s of silence as a dead stream.
  • Respect hidden tabs. On visibilitychange to hidden, keep the stream for a minute or two, then close it: background tabs are throttled anyway, and phones save battery. When the tab is visible again, reopen with the last event id.
  • Don't replay a long gap. If the user was away for 20 minutes, the server shouldn't replay 2,000 events. It answers with one reset event, and the client fetches a fresh new-post count for the banner and refreshes counts for the posts in view.
ts
const stream = new EventSource("/api/feed/stream", { withCredentials: true });
const pending = new Map<string, PostStats>();

stream.addEventListener("new-posts", (e) => {
  const { count, since } = JSON.parse(e.data);
  store.setNewPosts("home:top", count, since); // banner, or insert if at top
});
stream.addEventListener("post-stats", (e) => {
  const stats: PostStats = JSON.parse(e.data);
  const seen = pending.get(stats.id);
  if (!seen || stats.version > seen.version) pending.set(stats.id, stats);
});
setInterval(() => {
  if (pending.size === 0) return;
  store.applyStats([...pending.values()]); // one store update per second
  pending.clear();
}, 1000);
stream.onerror = () => {
  if (stream.readyState === EventSource.CLOSED) scheduleReconnect(); // backoff + jitter
};
Named events, newest-wins buffering per post, one store update per second, and a manual reconnect only when the browser has given up.

Check your understanding · Exercise 7

The stream reports 3 new posts while the user is reading post 40. Which behaviors are right? Select all that apply.

Photos and video without jumps or waste

Media is most of what a feed downloads, and most of what goes wrong. A page of 20 posts is about 40 KB of JSON but 2 to 4 MB of images. The first post's photo is usually the LCP element.

Images loading into boxes of unknown size are the main cause of layout shift. And video, played carelessly, burns data and battery for clips nobody watches.

Start with layout shift, because it's the easiest to get wrong. An img without a known size takes up zero height until its first bytes arrive, then grows to, say, 400 px, pushing every post below it down. If the user was reading the caption below, it jumps. Each jump adds to CLS, and a feed that doesn't reserve media space easily scores 0.25 or worse, well past the 0.1 budget.

Decision How should a post reserve space for its media before it loads?

Let media size itself

Render img and video with only a width, such as width: 100%, and let the height follow the file.

Pros

  • No data needed from the API.
  • No cropping, ever.

Cons

  • Every image shifts the layout when it loads.
  • Virtualization measures heights that change a moment later, causing more scroll corrections.

width and height attributes

Put the image's real pixel size on the tag (width="1080" height="1350") with CSS height: auto. Modern browsers turn the two numbers into an aspect ratio and reserve the box before loading.

Pros

  • Built into the browser, no wrapper needed.
  • Works for a single image in a column.

Cons

  • A 1080 by 4000 screenshot reserves a box taller than the screen.
  • Grids of 2 to 4 images and videos need their own layout rules anyway.

An aspect-ratio box per media slot

The API sends each item's width, height, and a placeholder. The card computes the box's ratio, clamps it to a range (for example, no taller than 4:5 and no wider than 1.91:1), and sets CSS aspect-ratio on a wrapper. The image fills it with object-fit: cover, over a placeholder color.

Pros

  • Zero layout shift for every kind of media, grids included.
  • Very tall or very wide media can't take over the feed.
  • The estimate for virtualization is exact before anything loads.

Cons

  • Clamped media is cropped in the feed; the full image shows when opened.
  • The API must always send dimensions, even for old posts.

What we pick: The aspect-ratio box, with dimensions from the API. It reserves exact space for every layout, single image, grid, or video, and it makes the virtualizer's height estimates exact too. Clamping the ratio is a product rule worth saying out loud: Instagram, for example, caps how tall a photo can be in its feed.

Three ways media loads in a post. Without a size, the image box starts empty and the caption below is pushed down when the image arrives, giving a CLS above 0.25. With aspect-ratio, a placeholder fills a 4 by 5 box at its final size, so the caption never moves and CLS is 0. For video, preload is none; a video 60 percent visible plays, and one 20 percent visible is paused.no sizeimage loadingcaptionpushed downCLS over 0.25aspect-ratioplaceholder, 4:5captionCLS 0video60% seen: play20%: pausedpreload=none
aspect-ratio from the API's width and height fixes the box before any pixels arrive. Video costs nothing until it's at least half on screen.

The right file at the right time

A feed column is about 500 to 680 CSS pixels wide on a laptop, and the full width of a phone, around 400 CSS pixels at 2 to 3 device pixels each. One file can't fit both. An image service on the CDN produces each photo at several widths, and the browser picks with srcset and sizes.

It also picks the format: AVIF or WebP, often 30 to 50% smaller than JPEG at the same visual quality, chosen by the CDN from the request's Accept header or with a picture element. A phone then downloads a 1080 px AVIF of about 100 KB instead of a 2,000 px JPEG of 600 KB.

html
<div class="media" style="aspect-ratio: 4 / 5; background: #c8b49a">
  <img
    src="https://img.example.com/p7/1080.jpg"
    srcset="https://img.example.com/p7/480.jpg 480w,
            https://img.example.com/p7/720.jpg 720w,
            https://img.example.com/p7/1080.jpg 1080w"
    sizes="(max-width: 680px) 100vw, 680px"
    alt="Asha on a mountain trail at sunrise"
    loading="lazy"
    decoding="async"
  />
</div>
The box has its final size and color before a byte of the image arrives. The first post's image drops loading="lazy" and adds fetchpriority="high".
  • The LCP image loads first. The first post's image gets fetchpriority="high" and no lazy loading. If the server renders the first posts, it can also add link rel="preload" with imagesrcset in the head, so the download starts before the body is parsed.
  • Everything else is lazy. loading="lazy" lets the browser start loading when the image gets close: Chrome uses a distance of about 1,250 px on fast connections and 2,500 px on slow ones. With virtualization, images mount near the viewport anyway, so this mostly matters for overscan posts.
  • Placeholders fill the wait. The API sends a dominant color (6 hex digits) or a BlurHash (a 20 to 30 character string that decodes to a blurry preview). The box shows it instantly, so the feed never looks broken while images stream in.
  • Alt text comes from the author. Let authors write alt text in the composer, and fall back to generated captions if your platform has them. An image without a text alternative is a blank to a screen reader user.
  • Cache for scrolling back. Virtualization unmounts images that scroll away. Serve them from versioned URLs with Cache-Control: public, max-age=31536000, immutable, so scrolling back up is answered by the browser cache.

Video: when to play, and when not to

Browsers decide whether a page may autoplay video with sound, and the answer on a feed is almost always no. What every major browser does allow is muted autoplay: a video with the muted attribute may play on its own. On iPhones it must also have playsinline, or it opens in fullscreen. Even then, video.play() returns a promise that can reject, for example in iOS Low Power Mode, so always catch it and fall back to showing a play button.

  • Load nothing up front. preload="none" with a poster image: 20 videos in a page cost 20 small posters, not 20 video downloads.
  • Play only what's watched. An IntersectionObserver with a threshold of 0.5 starts a video when at least half of it is visible and pauses it below that.
  • One at a time. If two videos are half visible, play the one with the larger visible share. Two muted videos playing at once is noise and double the data.
  • Respect the user. Don't autoplay when the user prefers reduced motion (prefers-reduced-motion: reduce), has turned on data saving (navigator.connection.saveData, where supported), or has switched autoplay off in your settings.
  • Stream, don't download. Use adaptive streaming (HLS, played natively in Safari and through Media Source Extensions with a library such as hls.js elsewhere), so the first second plays at a low bitrate and quality rises with the connection.
  • Release it. When a video unmounts, pause it, remove its src, and call load(), so the browser frees the media buffers. Ten forgotten video elements can hold tens of megabytes.
The states of a video in the feed. It starts as a poster with preload none. When half visible, it plays muted and inline. Below half visible, it pauses and keeps its position; visible again, it plays again. If play is rejected, or autoplay is off because of reduced motion or data saving, it shows a play button, and a tap plays it. When it scrolls far away, it is unmounted and its source removed.posterpreload=noneplayingmuted, inlinepausedkeeps positionplay buttonwaits for a tapunmountedsrc removed50% seenunder 50%seen againplay() rejectedtapno autoplayfar awayreduced motionor Save-Data
Every arrow is a rule: play at 50% visible, one at a time, only muted and inline. A rejected play() or a user preference lands on a plain play button, never a broken player.

Check your understanding · Exercise 8

A feed's CLS is 0.27 and its LCP is 3.8 s. Which changes help? Select all that apply.

The first page: server, client, or streamed

Everything so far happens after the feed is on screen. The first load is its own problem, and it decides LCP. The naive single-page app sends an empty HTML shell, then a JavaScript bundle, which then requests the feed, which then renders posts, which then request images. That's four steps in a row, each waiting for the one before: a waterfall.

Put numbers on it for a mid-range phone on 4G with a 150 ms round trip. The HTML shell arrives at 0.3 s. About 300 KB of compressed JavaScript downloads by 1.3 s and takes another 0.5 s to parse and run on a slow CPU. The feed request starts at 1.8 s and returns at 2.2 s.

React renders the first posts at 2.3 s, and only now does the browser discover the first image, which arrives at 2.9 s. LCP is about 3 s on a decent connection, and much worse on a bad one. The 2.5 s budget is gone before any optimization of the feed itself.

Decision How should the first page of the feed reach the screen?

Client-side rendering

The server sends an empty shell. JavaScript fetches the feed and renders it in the browser.

Pros

  • The simplest setup: static files on a CDN.
  • Every later navigation is fast, since the app is already loaded.

Cons

  • The waterfall above: LCP waits for JavaScript, then data, then the image.
  • A blank screen or skeleton for the first 2 seconds.

Server-side rendering, all at once

The server fetches the feed, renders the full page to HTML, and sends it. JavaScript then hydrates it: attaches event handlers to the existing HTML.

Pros

  • Posts and the first image are in the HTML, so the image download starts right away.
  • Content shows before JavaScript runs.

Cons

  • The server waits for the slowest data before sending a byte. A slow sidebar query delays the whole page.
  • Until hydration finishes, the page looks ready but taps do nothing, which users read as broken.

Streaming server rendering

The server sends the head and the page shell right away (header, composer button, skeleton), then streams each part as its data is ready: the first 5 posts, then the sidebars. In React this means Suspense boundaries rendered with a streaming renderer, as Next.js and Remix-style frameworks do.

Pros

  • The browser starts loading CSS, fonts, and JavaScript during the server's data fetch.
  • The first posts and their image stream in as soon as the feed query returns, and slow parts can't hold them back.
  • React hydrates each part separately and hydrates the one the user taps first.

Cons

  • Needs a server runtime and a framework that streams.
  • Personalized HTML can't be cached by a CDN, so every first visit costs server work.

What we pick: Streaming server rendering for the shell and the first 5 posts, then client-side fetching for every later page. LCP drops to about 1.5 s because the first image is in the HTML and preloaded, and slow side panels can't delay the feed. After that, the client owns the list: pagination, live updates, and virtualization all happen in the browser.

Three first-load timelines on a mid-range phone over 4G. Client-side rendering: HTML until 0.3 s, 300 KB of JavaScript until 1.8 s, the feed request until 2.2 s, and the image from 2.3 to 2.9 s, so LCP is 2.9 s. Server rendering: the server works until 0.8 s, the image loads until 1.6 s, then hydration runs until 2.5 s; LCP is 1.6 s. Streaming: the shell arrives by 0.4 s, posts stream by 0.8 s, the preloaded image arrives by 1.5 s, and hydration ends at 2.2 s; LCP is 1.5 s.CSRhtmlJS: 300 KBfeedimageLCP 2.9 sSSRserverimagehydrateLCP 1.6 sstreamedshellpostsimagehydrateLCP 1.5 s0 s1 s2 s3 s
Client rendering pays four steps in a row before the image even starts. Server rendering puts the image in the HTML, and streaming also lets the browser fetch code and styles while the server is still waiting for the feed.

Hand over to the client without doing the work twice

  • Send the data with the HTML. The server puts the first page's normalized data (posts, users, ids, nextCursor) in the HTML as JSON, and the store starts from it. Without this, the client fetches the same page again after hydrating. Escape the JSON for HTML (at least < as \u003c), or a post containing a closing script tag becomes an XSS hole.
  • Keep hydration small. Hydrate the feed and the header first. Load the composer editor, the comment box, the share dialog, and the video player with dynamic import() when they're first needed. Every kilobyte not hydrated at load is a kilobyte that can't block an early tap.
  • Consider server components. With React Server Components, the static parts of a post (header, text, media markup) can render on the server and ship no component code at all. Only interactive parts, the like bar and the menu, ship as client components.
  • Don't let early taps vanish. A like tapped before hydration finishes is lost in plain SSR. React's selective hydration replays a tap on a part that isn't hydrated yet, but only once React itself has loaded. For taps before that, render buttons disabled until hydrated, or make the like a real form post that works without JavaScript.

Return visits

People open the feed many times a day. Keep a snapshot of the first 20 posts of the normalized store in IndexedDB, about 50 KB, and on the next visit render it at once while the fresh first page loads. Mark it as old ("Updated 2 hours ago"), and don't swap the list under the user: if they've started scrolling when the fresh page arrives, offer the new posts through the banner instead. Personalized HTML must also never be stored by a shared cache: send it with Cache-Control: private.

Check your understanding · Exercise 9

Why does streaming server rendering usually give a better LCP than client-side rendering for a feed? Select all that apply.

Putting it together: the architecture

Every decision so far fits into three layers. Views draw what's in the store and turn taps into actions. The entity store is the one copy of every post, user, comment, and list.

Data clients are the only code that talks to the network, and every one of them writes to the store through the same merge rule. Outside the browser sit the feed API, the write API, the event stream, object storage for uploads, and the media CDN.

The news feed's architecture. In the browser, the user works with three views: the lazy Composer, the virtualized FeedList, and the PostCards it renders. Views select data by id from the Entity store, which holds posts, users, comments, and feed id lists. Three data clients write into the store: the Realtime client, an EventSource fed by the SSE stream; the Feed loader, which pages by cursor from the Feed API; and the Mutations client, for likes, comments, and posts, which calls the Write API and uploads straight to object storage. PostCards load images and video from the media CDN.userBrowserComposerlazyFeedListvirtualizedPostCardone per postEntity storeposts, users, comments, feed id listsselect by idRealtimeEventSourceFeed loadercursor, dedupeMutationslikes, commentsSSE streamFeed APIWrite APIMedia CDNimages, videoone copyof eachStoragesigned URL
Views only read from the store, and only the clients below it touch the network. Every write goes through one merge rule, so a late page, a pushed count, and the user's own like can't overwrite each other.
PartResponsibilityTalks to
FeedListVirtualized role="feed" list: renders posts near the viewport, measures them, keeps the scroll anchor, asks for the next page near the end.Store (ids), feed loader
PostCardOne post by id: header, text with entities, media box, like bar, comment box. Reads its own post with a selector.Store, mutations client
NewPostsBannerShows "3 new posts" from the feed's pending count; on click scrolls up and loads them.Store, feed loader
ComposerLazy-loaded editor with mentions, attachments with progress, drafts, and publish.Store, uploads, mutations client
Entity storePosts, users, comments by id; feed lists with cursors; like intents; pending comments and posts; upload slots.Read by views, written by clients
Feed loaderFetches pages by cursor, one at a time, dedupes ids, handles expired cursors and retries.Feed API
Mutations clientLikes with one request in flight per post, comments and posts with idempotency keys, uploads to signed URLs.Write API, object storage
Realtime clientOne EventSource: new-post hints, count updates once a second, watched-post list, reconnects.Event stream, watch endpoint
Impression trackerRecords posts seen at 50% for 1 s and sends them in batches with sendBeacon.Impressions endpoint

Follow a like through it. PostCard calls tapLike("p7"), which sets the intent in the store. The card re-renders from the store in the same frame: heart filled, count plus one. The mutations client sees the intent and sends PUT /posts/p7/like.

The response goes through the merge rule into posts["p7"]. If the profile page is open in another view, it reads the same row and shows the same thing. At no point does a view call fetch, and at no point does a client touch the DOM.

Why put the boundaries there? Because each one gives you one place for one kind of problem. All retries, auth headers, aborts, and dedupe live in the clients, so a bug in retry logic is fixed once.

All conflict handling lives in the merge rule, so a late page, a push, and a mutation response can't overwrite each other in different ways. And views that only read the store are easy to test: give them a store state, check the output. The store itself has no React in it, so its logic (merging, intents, eviction) runs in plain unit tests.

Think first

Why shouldn't PostCard fetch its own comments or call the like endpoint directly?

Data model: what lives where

A senior answer doesn't just list entities. It says where each piece of state lives and why there, because the wrong home causes real bugs: scroll position in component state is lost on Back, a pending comment in component state is lost when virtualization unmounts the card, and the selected tab outside the URL is lost on refresh.

StateWhere it livesWhy there
Posts, users, commentsNormalized store, in memoryOne copy shared by every screen; merged by version.
Feed lists: ids, cursor, status, new-post countStore, keyed like home:topSurvives navigation, so Back is instant.
Like intents, pending comments, queued postsStoreMust outlive the card that started them.
Expanded "See more" posts, typed but unsent commentsStore, by post idVirtualization unmounts cards; component state would reset.
Upload slots: progress and statusStoreThe composer and the queued post both show them.
Feed choice (Top or Latest), open postURL: ?view=latest, /posts/p7Shareable, and survives refresh.
Scroll anchorhistory.stateBelongs to one history entry, restored on Back.
Measured heightsVirtualizer cache, by post idPure layout data, rebuilt if lost.
Composer draftlocalStorage, debouncedSurvives a crash or a closed tab.
Last feed snapshotIndexedDBInstant first paint on the next visit.
Menu open, hover, focus ringComponent stateShort-lived and local; losing it on unmount is fine.
Where state lives. In memory, lost on reload: the entity store with posts, lists, intents, and uploads; component state such as an open menu or hover; and the virtualizer's cache of heights by post id. Surviving reload: the URL with the feed view and open post; history.state with the scroll anchor; localStorage with the composer draft; and IndexedDB with the last feed snapshot. Only the URL is shareable.memory: lost on reloadEntity storeposts, lists, intentsComponent statemenu open, hoverHeight cacheby post idsurvives reloadURL?view=latesthistory.statescroll anchorlocalStoragedraftIndexedDBfeed snapshotshareable: only the URL
Ask two questions of every piece of state: must it survive unmounting, and must it survive a reload? The answers pick its home.
ts
type Post = {
  id: string;
  authorId: string; // into users, never a copy
  createdAt: string;
  text: string;
  entities: Entity[]; // mentions, links, hashtags as ranges
  media: Media[]; // up to 4 photos or 1 video
  likeCount: number;
  commentCount: number;
  viewerLiked: boolean; // as last confirmed by the server
  commentIds: string[]; // the preview comments
  version: number; // higher wins on merge
  detail: "summary" | "full";
  status?: "sending" | "processing" | "failed"; // own posts only
};

type Media = {
  id: string;
  kind: "image" | "video";
  width: number; // for the aspect-ratio box
  height: number;
  placeholder: string; // dominant color or BlurHash
  sources: { url: string; width: number }[]; // for srcset
  posterUrl?: string; // video only
  alt?: string;
};
Server facts only: counts and viewerLiked are what the server last said. What the user asked for lives next to them, not inside them.
ts
type FeedState = {
  posts: Record<string, Post>;
  users: Record<string, User>;
  comments: Record<string, Comment>;
  feeds: Record<string, FeedList>; // "home:top", "home:latest", "profile:u2"
  likeIntents: Record<string, { desired: boolean; inFlight: boolean }>;
  uploads: Record<string, UploadSlot>;
  expanded: Record<string, true>; // "See more" opened
};

type FeedList = {
  ids: string[]; // server order, never re-sorted
  nextCursor: string | null; // null: the end
  status: "idle" | "loading" | "error";
  newCount: number; // for the banner
  newSince: string | null; // passed to GET /feed/new
};

type UploadSlot = {
  id: string;
  progress: number; // 0 to 100
  status: "uploading" | "processing" | "ready" | "failed";
  previewUrl: string; // from URL.createObjectURL
};
The store's shape. Tables by id, lists of ids, and the user's pending intent kept apart from server facts.

Two rules hold this together. First, server facts and user intent never share a field: viewerLiked is what the server said, and likeIntents is what the user asked for, so a push can update one without erasing the other. Second, everything in the store can be rebuilt from the server except intent and drafts, which is why those are the only things persisted or retried. If the tab is killed, the feed can be refetched, but a comment the user typed cannot.

Check your understanding · Exercise 10

A user opens "See more" on a long post, scrolls far down, and scrolls back. The post is collapsed again. What's the best fix? Pick one.

The interface: API and components

The API is where the earlier decisions become contracts: the feed endpoint returns normalized data with an opaque cursor, writes send desired state and idempotency keys, the stream sends hints and small numbers, and every error says whether it's worth retrying. The component props are the other contract: they decide what's easy to reuse and test.

Decision Should the feed be served by REST endpoints or GraphQL?

REST with normalized responses

GET /api/feed?cursor=... returns ordered ids plus flat posts and users arrays. Writes are plain PUT, DELETE, and POST requests.

Pros

  • Simple to cache, monitor, and rate limit per endpoint.
  • No client library needed: the store merges flat arrays.

Cons

  • Each screen's needs are fixed by the server; a new field means an API change.
  • Several screens may need several endpoints, or a backend for frontend to combine them.

GraphQL with Relay-style connections

Each component declares the fields it needs as a fragment; the page's query combines them. Lists follow the connection spec: edges with a cursor per item and pageInfo { hasNextPage, endCursor }.

Pros

  • Components ask for exactly what they render, so no over-fetching.
  • Relay and Apollo normalize by id automatically.
  • Streaming directives (@defer, @stream) fit a feed's first load.

Cons

  • A large client runtime and a build step.
  • Caching and rate limiting are harder when everything goes through one endpoint.

What we pick: Either works, and the right answer is usually whatever the company already runs. Facebook's own feed uses GraphQL with Relay. This guide shows REST because its shapes are easier to read on a whiteboard. What matters in both is the same: an opaque cursor, data normalized by id, and idempotent writes.

Reading the feed

http
GET /api/feed?view=top&cursor=c_8f2a&limit=20

{
  "items": [
    { "postId": "p9", "reason": "Asha Rao commented on this" },
    { "postId": "p7" }
  ],
  "posts": [
    { "id": "p7", "authorId": "u2", "text": "Sunrise from the ridge", "entities": [],
      "media": [{ "id": "m1", "kind": "image", "width": 1080, "height": 1350,
                  "placeholder": "#c8b49a", "sources": [...] }],
      "likeCount": 1204, "commentCount": 31, "viewerLiked": false,
      "commentIds": ["c88", "c90"], "version": 57, "detail": "summary" }
  ],
  "users": [{ "id": "u2", "name": "Asha Rao", "avatarUrl": "..." }],
  "comments": [{ "id": "c88", "postId": "p7", "authorId": "u5", "text": "Wow" }],
  "nextCursor": "c_91bd"
}
Ordered ids plus flat tables. The reason is ranking context the client shows but never computes. GET /api/feed/new?since=... returns the same shape for the banner.

Writing

http
PUT    /api/posts/p7/like        -> 200 { likeCount, viewerLiked: true, version }
DELETE /api/posts/p7/like        -> 200 { likeCount, viewerLiked: false, version }

POST   /api/posts/p7/comments
Idempotency-Key: 6b1e0c7a-2f4d-4a51-9c1e-0d7f3a2b9e44
{ "text": "Congrats!", "entities": [] }
                                  -> 201 { comment, post: { commentCount, version } }

POST   /api/uploads              { "type": "video/mp4", "size": 48211933 }
                                  -> 201 { uploadId, parts: [{ n, signedUrl }] }
POST   /api/uploads/:id/complete -> 202 { status: "processing" }

POST   /api/posts
Idempotency-Key: 0f4c...
{ "text": "...", "entities": [...], "mediaIds": ["m_41"], "audience": "friends" }
                                  -> 201 { post, users }
Desired state for likes, an idempotency key for everything that creates, and every response returns the new version so the store can merge it.

The event stream

text
GET /api/feed/stream        (Accept: text/event-stream)

id: 812
event: new-posts
data: {"feed":"home:top","count":3,"since":"s_77a"}

id: 813
event: post-stats
data: {"id":"p7","likeCount":1210,"commentCount":32,"version":59}

event: post-ready
data: {"id":"p40","version":3}

: heartbeat

POST /api/feed/watch   { "ids": ["p7", "p9", "p3"] }   (at most every 2 s)
POST /api/impressions  { "events": [{ "postId": "p7", "visibleMs": 1400, "position": 2 }] }
Hints and small numbers only. The id lines let the browser resume with Last-Event-ID; the comment line keeps proxies from closing an idle stream.

Errors the client can act on

Every error response has the same shape: { "error": { "code": "cursor_expired", "message": "...", "retryable": false } }. The client acts on the code, not on the message text. 401 means sign in again and then replay the request. 410 with cursor_expired means fetch a fresh first page and dedupe.

429 means wait for the Retry-After header. 5xx and network errors are retried with backoff, but only for idempotent requests or requests with an idempotency key. A POST without a key is never retried automatically.

Component props

tsx
type FeedListProps = {
  feedKey: string; // "home:top": which list in the store
  renderItem?: (id: string, position: number) => ReactNode; // ads, suggestions
  onImpression?: (id: string, visibleMs: number, position: number) => void;
};

type PostCardProps = {
  id: string; // reads everything else from the store
  position: number; // for aria-posinset and analytics
  surface: "feed" | "profile" | "detail"; // small layout differences
  onOpen?: (id: string) => void; // navigate to the post page
};
Ids in, store reads inside. PostCard works the same in the feed, on a profile, or on its own page, because it receives no data, only an id.

Check your understanding · Exercise 11

Which requests can the client safely retry after a timeout, without risking a duplicate effect? Select all that apply.

Accessibility: a feed everyone can move through

An endless list is hard to use without a mouse. Count the Tab stops in one post: the author's avatar link, name link, the time link, the menu button, links in the text, the media, like, comment, and share. That's 8 to 10 stops per post, so getting past 10 posts takes about 100 presses of Tab.

With infinite scroll, the footer is unreachable for everyone: it moves away every time a page loads. Move footer links to a sidebar or a menu.

The WAI-ARIA Authoring Practices describe a feed pattern made for exactly this. The container has role="feed" and a label. Each post is an article that can take focus, with aria-posinset (its position, starting at 1) and aria-setsize (the total, or -1 when, as here, the total is unknown).

Each article is labelled by its author and time with aria-labelledby. While posts are being added, the feed has aria-busy="true", so screen readers wait for the change to finish instead of reading half a page. Screen readers that support the pattern let users move article by article.

html
<h1 class="sr-only" id="feed-title">News feed</h1>
<div role="feed" aria-labelledby="feed-title" aria-busy="false">
  <article
    tabindex="-1"
    aria-posinset="82"
    aria-setsize="-1"
    aria-labelledby="p7-author p7-time"
    aria-describedby="p7-text"
  >
    <h2 id="p7-author"><a href="/u/asha">Asha Rao</a></h2>
    <a href="/posts/p7"><time id="p7-time" datetime="2026-10-17T06:12Z">3 h</time></a>
    <p id="p7-text">Sunrise from the ridge</p>
    <button aria-pressed="false">Like</button>
    <span>1,204 likes</span>
  </article>
  <!-- 5 to 10 articles mounted; the rest are spacers -->
</div>
Positions come from the full list, not the mounted window: post 82 says 82 even when it's the third element in the DOM.

The pattern also defines keys, and the feed must implement them, because browsers don't. Page Down moves focus to the next article and Page Up to the previous one. Control + End moves focus to the first focusable element after the feed, and Control + Home to the first one before it, so the user can escape an endless list in one press.

Many products add single-letter shortcuts, like J and K for next and previous. If you do, WCAG requires that single-character shortcuts can be turned off or remapped, or only work while focus is in the feed, so they don't fire while a speech input user is dictating.

Keyboard movement in a feed. Above the feed is a search box; below it, a sidebar link. Inside the role feed container are post 81, post 82 which has focus, and post 83. Page Down moves focus from post 82 to post 83, Page Up to post 81. Control plus Home jumps to the search box before the feed, and Control plus End to the sidebar link after it. Notes say post 82 has aria-posinset 82 and aria-setsize -1, and that the focused post stays mounted.search boxrole=feedpost 81post 82: focuspost 83sidebar linkPage DownPage UpCtrl + HomeCtrl + Endposinset 82setsize -1focused poststays mounted
Tab would walk through 8 to 10 controls in every post. The feed keys move a whole post at a time, and Control + End gets out of an endless list in one press.

What to announce, and what not to

  • New posts, once. When the banner first appears, a polite live region says "New posts available". Later count changes update the banner's text but aren't announced again.
  • Loading more is quiet. aria-busy on the feed covers it. If loading fails, the retry row appears with a polite message ("Couldn't load more posts") and a focusable Retry button.
  • Likes speak through the button. The like button is a toggle with aria-pressed, and its name stays "Like". Its state change is announced on its own. Keep the count in separate text, and never announce pushed count changes: on a busy post that would be a new announcement every second.
  • Failures that lose work are announced. "Your comment wasn't posted" must reach a screen reader user, because they can't see the faded comment. Put it in the live region and keep focus where it was.

Keeping focus safe in a changing list

  • Never unmount the focused article, even when virtualization would. Keep it mounted outside the window until focus moves.
  • After the user clicks the banner, move focus to the first new article, so keyboard users start reading at the right place.
  • When a post is removed (deleted, hidden, or the author blocked), move focus to the next article before removing it, and leave a same-height "This post was removed" placeholder if it's on screen, so nothing below jumps.
  • The full composer opens in a modal dialog: focus moves into it, Tab stays inside it, Escape closes it, and focus returns to the button that opened it.
  • Respect prefers-reduced-motion: no smooth scrolling to the top, no animated inserts, no video autoplay.
  • Make targets big enough: WCAG 2.2 asks for at least 24 by 24 CSS pixels, and 44 is comfortable on phones. Icon-only buttons (share, menu) need text labels for screen readers.

Check your understanding · Exercise 12

Which of these are part of an accessible feed? Select all that apply.

Performance budgets and how to keep them

Earlier chapters made choices for performance. This one turns them into budgets you can measure, because a budget nobody measures erodes one feature at a time. Measure at the 75th percentile of real visits, separately for phones and desktops, since phones are where a feed fails first.

MetricBudget (p75, mid phone, 4G)What threatens it in a feedMain defenses
LCP2.5 s max, aim for 1.8 sClient-side waterfall, a lazy first image, a big hero above the feedStreaming SSR, preloaded first image with fetchpriority="high", responsive AVIF
INP200 ms maxWhole-list re-renders, hydration, merging a large page, heavy menusPer-card selectors, lazy code, chunked work, transitions
CLS0.1 maxUnsized media, inserted posts or ads, late fontsAspect-ratio boxes, banner instead of insert, reserved slots, font fallbacks
JavaScript on first loadAbout 150 KB compressedEditor, video player, and dialogs in the main bundleDynamic import() on first use
ScrollingNo frame over 50 ms during a flingBig DOM, layout reads in scroll handlers, image decodingVirtualization, IntersectionObserver, decoding="async"
MemoryUnder 300 MB after 500 postsEvery post kept, leaked observers, unrevoked blob URLsVirtualization, entity eviction, cleanup on unmount
The design's estimated spend against each Core Web Vitals budget. LCP, budget 2.5 s: time to first byte 0.4 s, load delay 0.1 s, image load 0.5 s, and render delay 0.2 s, about 1.2 s in total. INP, budget 200 ms: a tap that lands during a page merge waits 60 ms, then the handler and render take about 8 ms and the paint about 16 ms, about 85 ms in total. CLS, budget 0.1: about 0.02, from fonts and late content.LCPTTFBimageabout 1.2 s of 2.5 sINPwaitingabout 85 ms of 200 ms, mostly waitingCLS0.02about 0.02 of 0.1, with media boxes reserved
Each dashed box is a whole budget. LCP has room for slower phones; INP is mostly waiting for the main thread, which is why chunked work matters more than a fast handler; CLS is spent almost entirely by media until boxes are reserved.

Spending the LCP budget

LCP splits into four parts, and each has an owner. Time to first byte: the server answers in about 0.4 s, because it streams the head before the feed query finishes. Load delay: the time until the image request starts, near 0 because it's preloaded from the head. Load time: a 100 KB AVIF on 4G takes about 0.5 s.

Render delay: the time from image arrival to paint, about 0.2 s if the post's HTML is already there and no script blocks it. That's about 1.1 to 1.5 s in total, leaving room for slower phones and networks. If LCP regresses, the breakdown tells you which owner to call.

Keeping taps fast

A like by itself is cheap: the store write takes under 1 ms and re-rendering one like bar a few more. INP goes bad when the tap lands while the main thread is busy with something else. In a feed, the usual culprits are merging and rendering a fresh page of 20 posts (30 to 60 ms on a slow phone), hydration on first load (hundreds of ms), processing impressions, and pushed updates re-rendering many cards.

  • Append pages inside startTransition, so React can pause rendering them when the user taps.
  • Split long work into pieces and yield between them: scheduler.yield() where supported, setTimeout(0) where it isn't (Safari). No single task should run much over 50 ms.
  • Apply pushed counts once a second, in one update, only for watched posts.
  • Do analytics and impression bookkeeping in idle time with requestIdleCallback (with a setTimeout fallback in Safari), never inside a click handler.
  • Keep menus and dialogs light: render a post's menu only when opened, not 500 hidden menus.

Keeping the layout still

Media is the big CLS source and the aspect-ratio box fixes it. Three smaller sources remain. The new posts banner must float over the feed (position: sticky or fixed) instead of pushing it down.

Anything inserted into the list after render, such as an ad or a "People you may know" unit, needs its slot reserved at its final height before it loads, or it pushes every post below it down. And a web font that swaps in with different letter widths reflows every post's text, so use a fallback font adjusted to match (size-adjust) or font-display: optional.

Measuring in the field

Lab tests on a fast laptop miss most feed problems, because they come from slow phones and long sessions. Collect real-user data with the web-vitals library, whose attribution build tells you which element was the LCP, which interaction was slow, and what shifted. Send it with sendBeacon when the page is hidden.

Tag each sample with device class (navigator.deviceMemory in Chromium), connection type, and how many posts were loaded, so you can see whether INP grows with session length. Chromium's Long Animation Frames API points at the scripts behind slow frames. In CI, run a scripted 500-post scroll on a throttled phone profile and fail the build if JavaScript size, DOM node count, or heap size passes its budget.

Check your understanding · Exercise 13

INP is fine for most users but poor for users who have loaded more than 300 posts. What are likely causes? Select all that apply.

Running it in the interview

You now know more about feeds than fits in 45 minutes. The interview is about choosing what to say, in what order, and showing that each choice comes from a requirement. Here's a plan that leaves room for the interviewer to steer.

MinutesWhat to doWhat's on the board at the end
0 to 5Ask the scope questions. Agree on devices, ranking, media, freshness, and session length.The numbers: 2.5 s, 200 ms, 0.1, 500 posts, 30 s
5 to 10Name the hard parts in one breath, then draw the three layers.Views, store, clients, and the servers outside
10 to 30Go deep on three decisions with options and a verdict each. Default picks: cursor pagination with virtualization, the normalized store with optimistic likes, and new posts behind a banner.A cursor diagram, a virtual window, a like sequence
30 to 38Data model with where each piece lives, then the feed endpoint and the stream events.Types, one response shape, three event names
38 to 45Performance budgets, accessibility, then the interviewer's follow-ups.A short list of what you'd measure
A 45-minute interview plan as a timeline. Minutes 0 to 5: scope questions. 5 to 10: the hard parts and the three layers. 10 to 30: three deep dives. 30 to 38: data model and API. 38 to 45: performance, accessibility, and follow-ups. Notes say what's on the board: the numbers and three layers; a cursor, a virtual window, and a like sequence; types, an endpoint, and budgets.Scope0 to 5Layers5 to 103 deep dives10 to 30Data, API30 to 38Polish38 to 45numbers and3 layerscursor, window,like sequencetypes, endpoint,budgets
Almost half the time goes to three deep dives. Everything before them sets up the map; everything after shows you can turn the design into contracts and numbers.

Draw the architecture before any detail, because every later deep dive points at a box in it. Then let the interviewer choose: "I'd like to go deep on pagination and virtualization, the store and optimistic likes, and live updates. Is there one you'd rather I start with?" That one question shows you know where the difficulty is and lets them steer you to what they score. If they don't care, start with the store: it's what most candidates skip, and it makes the like and live-update stories simple to tell.

While you talk, say the trade-off out loud every time: what the naive version does, the scenario where it breaks with a number, the options, and why you pick one. "Offset pages repeat posts when 3 arrive at the top, so I use an opaque cursor over a ranked snapshot" is a senior sentence. "I'd use cursor pagination" is a junior one. They name the same choice; only one shows the reasoning.

Think first

The interviewer asks: "How would ads or 'Suggested for you' units fit into this feed?"

Think first

The interviewer asks: "Product wants a Top and Latest switch. What changes?"

Think first

The interviewer asks: "How would you test this?"

Check your understanding · Exercise 14

Ten minutes in, you've drawn the architecture. What's a strong next move? Select all that apply.

Summary

  • A feed changes while it's read, so page with an opaque cursor over a ranked snapshot, keep one page request in flight, dedupe ids, and never re-sort on the client.
  • Long sessions on weak phones need virtualization with heights cached by post id, scroll corrections in the same frame, anchor-based restore, and a memory plan for the store and decoded images.
  • Store every post, user, and comment once, by id, with lists of ids; merge every server copy by version, and let each card subscribe only to its own post.
  • Make likes optimistic with an intent per post: one request in flight, desired state sent with PUT or DELETE, last tap wins, rollback to the last confirmed state. Comments get temporary ids and idempotency keys.
  • Upload media as soon as it's picked, straight to storage with a signed URL and visible progress, and store mentions as id ranges, not text.
  • Never move what the user is reading: new posts wait behind a banner that's announced once, counts arrive over SSE only for watched posts and at most once a second.
  • Reserve every media box with aspect-ratio from API dimensions, preload the first image, serve responsive AVIF, and autoplay video only muted, inline, half visible, and one at a time.
  • Stream the shell and the first posts from the server for LCP, then hand the list to the client, and track field budgets: LCP 2.5 s, INP 200 ms, CLS 0.1.

Check your answer

Tick what your own design covered. Pick the level you're interviewing for: each level includes the ones before it.

Junior 0 of 4
Mid-level 0 of 5

References

Concepts behind it

Try next