Topic 25.3
Design a News Feed
In one line
A news feed is an endless, personalised list that keeps growing at the top while the user scrolls down; the design rests on cursor pagination, a normalised store, a virtualised list, a new-posts banner instead of jumping content, optimistic likes and careful image loading.
Think of it like this
A conveyor belt of letters at a post office. New letters keep arriving at the front while you read from the back. If you counted "I've read 30 letters, give me letters 31 to 40", the new arrivals would shift the count and you'd get some letters twice. Instead you remember the last letter you read and ask for the ones after it. That bookmark is a cursor, and it is the heart of a feed.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- Offset pagination
- Asking for items by position: "skip 20, give me 20". Breaks when items are added or removed above.
- Cursor pagination
- Asking for items after a bookmark the server gave you: "20 items after this cursor". Stable when the list changes.
- Opaque cursor
- A string the client stores and sends back but never reads or builds itself.
- Normalised store
- Each entity stored once by id in a lookup table; lists hold ids, not copies.
- Virtualised list
- A list that renders only the rows near the screen and fakes the rest with spacer height.
- Sentinel
- An invisible element near the end of a list; when it scrolls into view, the next page loads.
- Optimistic update
- Showing the result of an action before the server confirms it, and undoing it if the server says no.
- Idempotent
- Doing it twice has the same effect as doing it once.
PUT /likeis;POST /toggle-likeisn't.
Step by step
01Requirements and clarifying questions
Ask: behind login? (yes: SEO not needed). Text only or images and video? (text plus up to 4 images). How fresh? (new posts within ~30 s is fine). Mobile web? (yes, so budget for a mid-range phone). Then write the numbers: 20 posts per page, 500+ posts per session, LCP < 2.5 s, INP < 200 ms, CLS < 0.1.
Out of scope unless asked: composing posts with video, comments threads, ranking (the server ranks; the client shows the order it's given).
02Architecture
The server ranks and paginates. The client has an API layer, a normalised feed store (or a server cache with an infinite query), a real-time channel for "new posts exist" events, and the views. The feed shell can be server-rendered with the first page inlined for a fast LCP, then the client takes over.
03Component tree
FeedPage owns data loading. FeedList is the virtualised list; each PostCard reads its own post from the store by id, so a like re-renders one card, not the whole list.
04Data model
Entities are stored once; the feed is a list of ids. The cursor and pending new ids sit next to the list they belong to.
type User = { id: string; name: string; avatarUrl: string };
type Media = { url: string; width: number; height: number; alt: string; placeholder?: string };
type Post = {
id: string; authorId: string; text: string; media: Media[];
createdAt: string; likeCount: number; likedByMe: boolean; commentCount: number;
};
type FeedState = {
postsById: Record<string, Post>;
usersById: Record<string, User>;
feedIds: string[]; // order from the server
nextCursor: string | null; // null = end of feed
pendingNewIds: string[]; // arrived, not shown yet
status: "idle" | "loading" | "error";
};05Interface (API)
One endpoint per job. Pages return posts plus the users they reference (so the client can normalise without extra calls). Likes are idempotent. New posts come as a small event; the client fetches them only when the user asks.
GET /api/feed?limit=20&cursor=eyJ0IjoxNzA1MzEyMjAwLCJpZCI6InA4In0
-> 200 { "items": Post[], "users": User[], "nextCursor": "eyJ0Ijo..." | null }
GET /api/feed?limit=20&before=p11 # newer than the top post, for the banner
-> 200 { "items": Post[], "users": User[] }
PUT /api/posts/p7/like -> 200 { "likeCount": 13, "likedByMe": true }
DELETE /api/posts/p7/like -> 200 { "likeCount": 12, "likedByMe": false }
SSE /api/feed/events
event: feed.new
data: { "count": 3, "newestId": "p14" }06Deep dive: loading the next page
A sentinel after the last rendered row triggers loadMore() when it is within one screen of the viewport. loadMore does nothing if a page is already loading or nextCursor is null (end of feed: show "You're all caught up"). On error, show an inline Retry row instead of the sentinel, so the user isn't stuck.
07Deep dive: images, accessibility, budget
Reserve image space with width/height, serve sizes with srcset, lazy-load below the fold, prioritise the LCP image. Mark the list role="feed", each post <article aria-posinset aria-setsize="-1">, set aria-busy="true" while a page loads, and make the like button a real <button aria-pressed>. Keep the feed route under the JavaScript budget by code-splitting the composer and comment threads.
Try it yourself
- 1
Prove the duplicate
In the cursor example, predict what an offset request
byOffset(3, 3)would return before the new post arrives. Move theconsole.log("offset page 2: ...")line aboveserver.unshift(...)and check. - 2
Two new posts
Push a second post (
p12) right afterp11and call the listeners again. Predict the banner text and the finalfeed:line. Which order should the new posts appear in? - 3
Make the server slower
In the likes example, change
wait(150)towait(20). Predict: is the second click still ignored? Why does the guard only matter while a request is in flight?
Code & diagrams
After `p11` arrives, an offset request for "page 2" returns `p8` again (a duplicate), while the cursor ("after p8") returns `p7, p6, p5`. The new post waits behind the banner until clicked, so the list never jumps under the reader.
Preview
Console
Nothing logged yet.Expected console output
feed: p10, p9, p8
offset page 2: p8, p7, p6
cursor page: p7, p6, p5
feed: p10, p9, p8, p7, p6, p5
feed: p11, p10, p9, p8, p7, p6, p5Both likes show instantly. The second click on the first post is ignored while its request is in flight. The server rejects the like on `p2`, so it is rolled back and an announced status explains why.
Preview
Console
Nothing logged yet.Expected console output
ignored second click on p1
right after clicks: ♥ 5 | ♥ 6
server confirmed p1
rolled back p2: This post was removed
after the server: ♥ 5 | ♡ 5function PostCard({ id, index }: { id: string; index: number }) {
const post = useFeedStore((s) => s.postsById[id]); // one card re-renders on a like
const author = useFeedStore((s) => s.usersById[post.authorId]);
return (
<article aria-posinset={index + 1} aria-setsize={-1} aria-labelledby={`a-${id}`} tabIndex={0}>
<h2 id={`a-${id}`}>{author.name}</h2>
<p>{post.text}</p>
{post.media.map((m) => (
<img
key={m.url}
src={m.url}
srcSet={`${m.url}?w=400 400w, ${m.url}?w=800 800w`}
sizes="(max-width: 600px) 100vw, 600px"
width={m.width}
height={m.height} // reserves space: no layout shift
alt={m.alt}
loading={index === 0 ? "eager" : "lazy"}
fetchPriority={index === 0 ? "high" : "auto"} // the likely LCP image
/>
))}
<LikeButton id={id} />
</article>
);
}Break it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Merge pages without de-duplicating
In the cursor example's reducer, replace the fresh line with const fresh = a.items.map((p) => p.id); (no check for ids already in the store), then make loadMore use offsets by replacing const page = await byCursor(s.cursor, 3); with const items = await byOffset(s.ids.length, 3); const page = { items, nextCursor: "x" };.
Myth vs fact
Myth
Infinite scroll is just appending pages to an array.
Fact
A production feed also de-duplicates by id, bounds the DOM with virtualisation, keeps scroll position stable when content arrives, handles end-of-feed and errors, and restores position when the user comes back.
Myth
Optimistic updates are only for speed demos.
Fact
For frequent, low-risk actions like likes, waiting 300 ms for every tap makes an app feel broken. The cost is rollback logic, which is small when the action is idempotent.
Myth
New posts should appear at the top automatically.
Fact
Inserting content above what the user is reading moves it, a layout shift that loses their place. A banner lets them choose when to see new posts.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
Rolling back by re-applying the inverse action (toggle again) is safer than restoring a snapshot: if a real-time event updated the like count while the request was in flight, a snapshot restore would erase that update. TanStack Query's
onMutate/onErrorpattern and React 19'suseOptimistic(Phase 19) solve the same problem:useOptimisticshows a temporary value that disappears when the transition ends, so the base state never needs rolling back. - ▸
Keep memory bounded, not only the DOM: after a few thousand posts, drop entities far above the viewport from the store and keep their cursor so scrolling back up can refetch them. Most apps never need this, but heavy-scroll products (social, logs) do.
- ▸
Scroll restoration: when the user opens a post and presses Back, they expect the same spot. Keep the feed data in a cache that survives navigation (a server cache with a long
gcTime) and store the scroll offset or the first visible id per history entry (Phase 24.3). - ▸
Ranking is a backend concern, but the client can help: send impression events (which posts were actually visible, measured with
IntersectionObserver, batched and sent withsendBeacon) so the ranking service learns what users saw.
Remember this
- 1
Requirements. Functional: show an endless feed of posts (text, up to 4 images, author, time, like and comment counts), load more while scrolling, like and unlike, show that new posts exist without moving what the user is reading. Non-functional, with numbers: 20 posts per page, heavy users scroll 500+ posts in a session, first posts visible with LCP under 2.5 s on a mid-range phone, INP under 200 ms for a like, smooth 60 fps scrolling, CLS under 0.1 (images must not push text around), keyboard and screen-reader support, works on 320 px wide screens. Clarify: is it behind login (usually yes, so SEO doesn't matter), how fresh (seconds is fine), web only or also mobile web?
- 2
Cursor pagination. Offset pagination asks for "items 20 to 39". When new posts are inserted at the top, every item shifts down, so the next page repeats items (or skips them after deletes). Cursor pagination asks for "20 items after this one": the server returns
nextCursor, an opaque string (the client must not read or build it; it usually encodes the last item's sort key, like its time and id) and the client sends it back. New posts at the top don't affect what comes after the cursor. The live example shows offsets duplicating a post while cursors don't. - 3
Normalised store. Normalising means storing each entity once, in a lookup table by id (
postsById,usersById), and keeping lists as arrays of ids (feedIds). When the same author appears on 50 posts, their name is stored once; when a like changes postp7, one object changes and every place showing it updates. Merging a new page becomes "add the ids I don't have yet", which also de-duplicates posts that arrive twice. Normalisation is the same idea as database normal forms, applied to client state (Phase 15). - 4
Virtualised infinite list. After 500 posts with about 40 DOM nodes each, the page holds 20,000 nodes and scrolling stutters. A virtualised list renders only the posts near the viewport plus a few extra (overscan) and uses spacer height for the rest (Phase 16.9 and 24.1). Feed posts have variable heights, so the list measures each row after it renders and keeps an estimate for rows it hasn't measured. A sentinel element near the end, watched by
IntersectionObserverwith arootMarginof about one screen, starts loading the next page before the user reaches the bottom (Phase 24.2). - 5
New-posts banner and optimistic likes. New posts arrive by polling or a push channel (Server-Sent Events or WebSocket, Phase 24.6). Inserting them at the top while the user reads would shift the content they're looking at, so they wait in a
pendingNewIdslist behind a "Show 3 new posts" button; clicking it prepends them and scrolls to the top. Optimistic likes update the count instantly, send the request, and undo the change if it fails. Make the endpoint idempotent (PUT /posts/:id/liketo like,DELETEto unlike) so a retry can't double-count, and ignore a second click while the first request is in flight. - 6
Images, accessibility and budget. Every image has
widthandheight(or CSSaspect-ratio) so space is reserved and CLS stays low,srcset/sizesso phones download small files,loading="lazy"below the fold, andfetchpriority="high"on the first post's image if it is the LCP element. A blurred placeholder (a tiny preview) makes loading feel faster. For accessibility the list uses the ARIA feed pattern: arole="feed"container witharia-busywhile loading, and each post an<article>witharia-posinsetandaria-setsize(-1when the total is unknown). The performance budget: initial JavaScript under about 170 KB gzipped, the feed route code-split from everything else, and the DOM kept bounded by virtualisation.
Explain it without notes
Why do feeds use cursor pagination instead of offset pagination?
What does it mean to normalise feed data, and what does it buy you?
How would you handle new posts arriving while the user is reading?
Design the like button: what happens on click, on failure, and on a double click?
How do you keep a feed fast after the user has scrolled through 1,000 posts?
Practice
Add an end-of-feed message to the cursor example: when cursor is null after at least one page, show You're all caught up.
Make the like rollback announce the error and clear it after the next successful like.
Trade-offs
- ↔
Infinite scroll keeps people reading but hides the footer, makes "find that post again" hard and is weaker for SEO. A "Load more" button or numbered pages suit search results and catalogues better (Phase 24.2).
- ↔
Server-rendering the first page improves LCP but needs the server to fetch personalised data per request and adds hydration cost. Many logged-in feeds render a skeleton shell on the server and fetch the first page on the client, inlining it only when LCP numbers demand it.
- ↔
Optimistic updates feel instant but can briefly show a state the server rejects. Use them for frequent, reversible actions (likes, follows), not for payments or deletes that can't be undone.
Done when you can
I can write feed requirements with numbers and the right clarifying questions.
I can draw the feed's architecture and component tree.
I can explain cursor pagination and show why offsets duplicate posts.
I can design a normalised feed store and merge pages without duplicates.
I can build optimistic likes with rollback and a double-click guard.
I can name the image, accessibility and performance rules for a feed.