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.
| Question | Answer we assume | What 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. |
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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
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?
For the first 30 posts, nothing. The first thing users notice is usually duplicates: if the next page is fetched by offset, posts that arrived at the top push already-seen posts onto page 2. That happens on the first page fetch of any busy feed.
Next, after a few hundred posts on a phone, scrolling stutters and taps feel slow, because the DOM has 50,000 to 100,000 nodes and every like re-renders the whole list. Later still, the tab runs out of memory and the browser reloads it, losing the user's place. Each of these needs its own decision: cursors, per-post subscriptions, and virtualization with memory limits.
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=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?
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.
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;
}
}
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
loadNextPageprevents it. - A clear end. When
nextCursorisnull, 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 Goneor 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.
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?
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.
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.
- 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.
- Measure. When a post mounts, a
ResizeObserverreports its real height. It also reports later changes: an image loading, "See more" expanding the text, comments opening. - 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.
- 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
scrollTopin 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'stoportransformchanges, which is how virtualizers place posts. - 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.
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>
));
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
IntersectionObserverper 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.
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?
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.
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.
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,
};
}
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?
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.
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
}
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.
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.
{
"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"
}
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?
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.
Progress, size, and resume
- Progress needs
XMLHttpRequest.fetchhas no upload progress events, butxhr.upload.onprogressreports bytes sent and the total. Show a bar per file withrole="progressbar"(or aprogresselement) and a Cancel button that callsxhr.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 withcanvas.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 theimgorvideocan show before any byte is uploaded. CallURL.revokeObjectURLwhen 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
uploadingtoprocessingtoready(orfailed), and the client learns of it from the live connection or by pollingGET /uploads/:idevery few seconds.
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() };
}
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?
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.
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?
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.
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
EventSourcegives up. It reconnects by itself after network drops, but if the server answers with an error status such as502, the browser closes the stream for good (readyStatebecomesCLOSED). 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
visibilitychangeto 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
resetevent, and the client fetches a fresh new-post count for the banner and refreshes counts for the posts in view.
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
};
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?
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.
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.
<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>
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 addlink rel="preload"withimagesrcsetin 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 aposterimage: 20 videos in a page cost 20 small posters, not 20 video downloads. - Play only what's watched. An
IntersectionObserverwith 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 callload(), so the browser frees the media buffers. Ten forgotten video elements can hold tens of megabytes.
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?
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.
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.
| Part | Responsibility | Talks to |
|---|---|---|
FeedList | Virtualized 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 |
PostCard | One post by id: header, text with entities, media box, like bar, comment box. Reads its own post with a selector. | Store, mutations client |
NewPostsBanner | Shows "3 new posts" from the feed's pending count; on click scrolls up and loads them. | Store, feed loader |
Composer | Lazy-loaded editor with mentions, attachments with progress, drafts, and publish. | Store, uploads, mutations client |
| Entity store | Posts, users, comments by id; feed lists with cursors; like intents; pending comments and posts; upload slots. | Read by views, written by clients |
| Feed loader | Fetches pages by cursor, one at a time, dedupes ids, handles expired cursors and retries. | Feed API |
| Mutations client | Likes with one request in flight per post, comments and posts with idempotency keys, uploads to signed URLs. | Write API, object storage |
| Realtime client | One EventSource: new-post hints, count updates once a second, watched-post list, reconnects. | Event stream, watch endpoint |
| Impression tracker | Records 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?
Because cards mount and unmount constantly under virtualization. A request started by a card that's then unmounted either gets lost or writes into a component that no longer exists, and the like's intent queue would vanish with it.
It would also duplicate logic: each card would need its own retries, dedupe, and merge rule, and two cards showing the same post (feed and sidebar) would race each other. Keeping network code in clients that outlive any view, and writing results to the store, avoids all of that.
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.
| State | Where it lives | Why there |
|---|---|---|
| Posts, users, comments | Normalized store, in memory | One copy shared by every screen; merged by version. |
| Feed lists: ids, cursor, status, new-post count | Store, keyed like home:top | Survives navigation, so Back is instant. |
| Like intents, pending comments, queued posts | Store | Must outlive the card that started them. |
| Expanded "See more" posts, typed but unsent comments | Store, by post id | Virtualization unmounts cards; component state would reset. |
| Upload slots: progress and status | Store | The composer and the queued post both show them. |
| Feed choice (Top or Latest), open post | URL: ?view=latest, /posts/p7 | Shareable, and survives refresh. |
| Scroll anchor | history.state | Belongs to one history entry, restored on Back. |
| Measured heights | Virtualizer cache, by post id | Pure layout data, rebuilt if lost. |
| Composer draft | localStorage, debounced | Survives a crash or a closed tab. |
| Last feed snapshot | IndexedDB | Instant first paint on the next visit. |
| Menu open, hover, focus ring | Component state | Short-lived and local; losing it on unmount is fine. |
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;
};
viewerLiked are what the server last said. What the user asked for lives next to them, not inside them.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
};
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?
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
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"
}
reason is ranking context the client shows but never computes. GET /api/feed/new?since=... returns the same shape for the banner.Writing
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 }
The event stream
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 }] }
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
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
};
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.
<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>
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.
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-busyon 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.
| Metric | Budget (p75, mid phone, 4G) | What threatens it in a feed | Main defenses |
|---|---|---|---|
| LCP | 2.5 s max, aim for 1.8 s | Client-side waterfall, a lazy first image, a big hero above the feed | Streaming SSR, preloaded first image with fetchpriority="high", responsive AVIF |
| INP | 200 ms max | Whole-list re-renders, hydration, merging a large page, heavy menus | Per-card selectors, lazy code, chunked work, transitions |
| CLS | 0.1 max | Unsized media, inserted posts or ads, late fonts | Aspect-ratio boxes, banner instead of insert, reserved slots, font fallbacks |
| JavaScript on first load | About 150 KB compressed | Editor, video player, and dialogs in the main bundle | Dynamic import() on first use |
| Scrolling | No frame over 50 ms during a fling | Big DOM, layout reads in scroll handlers, image decoding | Virtualization, IntersectionObserver, decoding="async" |
| Memory | Under 300 MB after 500 posts | Every post kept, leaked observers, unrevoked blob URLs | Virtualization, entity eviction, cleanup on unmount |
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 asetTimeoutfallback 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.
| Minutes | What to do | What's on the board at the end |
|---|---|---|
| 0 to 5 | Ask 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 10 | Name the hard parts in one breath, then draw the three layers. | Views, store, clients, and the servers outside |
| 10 to 30 | Go 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 38 | Data model with where each piece lives, then the feed endpoint and the stream events. | Types, one response shape, three event names |
| 38 to 45 | Performance budgets, accessibility, then the interviewer's follow-ups. | A short list of what you'd measure |
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?"
As more item types in the same list. The feed's items become a union: { type: "post", postId }, { type: "ad", adId }, { type: "suggestions", userIds }. The server decides where they go, since ad placement is part of ranking, and the client renders each type with its own component through the renderItem prop.
Each type has its own rules: ads need exact impression tracking (often 50% visible for 1 s, measured the same way as posts) and must reserve their height before loading to keep CLS down. Third-party ad scripts should run in sandboxed iframes, so they can't read the page or block the main thread.
Think first
The interviewer asks: "Product wants a Top and Latest switch. What changes?"
Very little, because lists are already separate from entities. Each view is its own feed list in the store, home:top and home:latest, with its own ids, cursor, new-post count, and scroll anchor. Both read the same posts table, so a like in one shows in the other.
Keep the choice in the URL (?view=latest) so it survives refresh and can be shared, and keep both lists in memory so switching back is instant and lands on the same post.
Think first
The interviewer asks: "How would you test this?"
Most of the risk is in plain logic, so test it as plain logic: the merge rule (older version ignored, summary never overwrites full), the like intent queue (fast taps, reordered responses, failure rollback), page appends with dedupe, and stream buffering. These run in milliseconds with no browser.
Then component tests for a card given a store state, an end-to-end test with a mocked API for scroll, like, banner, and Back, and a performance test that scrolls 500 posts on a throttled phone profile and checks DOM size, heap, and long frames. Add automated accessibility checks plus a manual pass with a screen reader on the feed pattern.
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
PUTorDELETE, 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-ratiofrom 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.
References
- Feed pattern, WAI-ARIA Authoring Practices Guide The roles, attributes, and keyboard model for an endless list of articles.
- Normalizing state shape, Redux docs Why and how to store entities by id with lists of ids.
- useOptimistic, React docs React's built-in hook for optimistic UI inside one component.
- Using server-sent events, MDN
EventSource, named events, event ids, and reconnection. - Intersection Observer API, MDN Sentinels for loading more, impressions, and video autoplay.
- Web Vitals, web.dev The definitions and thresholds for LCP, INP, and CLS.
- Optimize Cumulative Layout Shift, web.dev Reserving space for images, embeds, and late content.
- Browser-level image lazy loading, web.dev How
loading="lazy"decides when to fetch, and why not to lazy load the LCP image. - content-visibility, web.dev The CSS alternative to virtualization for shorter lists.
- TanStack Virtual A headless virtualizer with dynamic measurement, used in the code examples.
Concepts behind it
- Pagination: offset, cursor, and keysetWays to load a long list one piece at a time, and why the choice decides whether users see duplicates, miss items, or wait on slow deep pages when the list changes.
- Normalizing stateStoring each entity once, in a table keyed by its id, and having lists and relationships point at ids instead of holding copies, so one change updates every view that shows it.
- List virtualizationRendering only the rows the user can see in a long list, so a list of 100,000 items costs about as much as a list of 30.
- Optimistic updatesShowing the result of a user's action at once, before the server confirms it, and quietly undoing it if the server says no: the trick that makes likes, checkboxes, and chat messages feel instant on any network.
- Choosing a real-time techniqueHow to pick between short polling, long polling, server-sent events, WebSockets, and WebRTC for each feature, starting from who talks, how often, and how fresh the data must be, then checking scale, proxies, battery, and the fallback you will ship.
- Images and mediaSending each user an image of the right size and format, reserving its space, and loading it at the right moment: images are usually the largest share of a page's bytes and its LCP element.
- Streaming server renderingThe server sends a page's HTML in pieces as each part becomes ready, instead of waiting for its slowest data: the layout paints right away, slow sections stream in later, and each piece hydrates on its own.
- Core Web VitalsThree numbers, measured on real users, that say whether a page loads fast (LCP), reacts fast (INP), and stays still (CLS): what each one measures, what makes it bad, and how to fix it.