01 — Requirements

“Design a news feed.”

Before writing any code, narrow the problem. The panel on the right shows five scope questions — toggle them and watch the scope summary change.

Multiple content types? Text posts are easy. Adding images means responsive sizing, lazy loading, and aspect ratio management. Links need OG metadata fetching and preview rendering. Polls need a voting UI and real-time results. Each type is a rendering subsystem.

Reactions and comments? Likes alone introduce optimistic UI — the heart should turn red before the server confirms. Comments introduce threading, pagination, and potentially real-time updates. Both require separate API endpoints.

Real-time updates? New posts arriving while the user is mid-scroll. If you auto-insert, you shift their reading position. If you don't, the feed goes stale. The solution is a queue-and-banner pattern — and it's the kind of detail that separates a competent answer from a great one.

Algorithmic feed? Chronological is simple — sort by timestamp. Ranked means you need an engagement scoring formula, and the sort order can change between fetches. The client can't know the ranking server-side; it has to trust the server's order and re-rank locally only within a page.

Scale: 1K vs 1M posts? At 1K, everything fits. At 1M, you need virtualization (DOM recycling), cursor pagination, CDN images, and memory management. The architecture must degrade gracefully.

For this walkthrough: all content types, reactions, real-time via SSE, algorithmic + chronological, scales to 50K.

02 — API Design

The right panel shows two tabs — Endpoints lists the four routes our feed needs, Types shows the data contracts. Click any endpoint card to expand its parameters.

A news feed that supports mixed content, reactions, infinite scroll, and real-time needs four endpoints:

GET/api/feed is the main timeline — paginated feed items sorted by the requested mode (chronological or ranked). It returns FeedItem[ ], not full post detail. A FeedItem carries the text, author summary, reaction counts, and type-specific metadata (image URL, link OG data, or poll options). It deliberately omits comments, edit history, and full media — those load on demand when a user expands a post.

GET/api/posts/:id returns PostDetail — comments, share count, edit history, and everything needed for an expanded view. This is fetched only when a user clicks into a specific post. Fetching detail for 20 posts upfront would waste bandwidth on data 95% of users never see.

POST/api/posts/:id/react toggles a reaction. The response includes the server-authoritative count, which the client uses to reconcile after an optimistic update. If the client optimistically incremented to 43 but the server says 44 (someone else liked it too), the client adopts 44 silently.

GET/api/feed/updates is an SSE (Server-Sent Events) stream. It pushes new post events to the client without polling. Unlike WebSocket, SSE is unidirectional (server client), which is exactly what a feed needs — the client doesn't push posts to the server through this channel.

Switch to Types to see the split: FeedItem (~200bytes) vs PostDetail (~2KB). This 10× size difference across 20 posts means the split saves 36KB per page load.

03 — Component Architecture

The right panel shows the component hierarchy with four interactive scenarios. Hover any node to see data flowing between components. Use the scenario selector to watch different user flows animate through the tree.

Feed is the single state owner. It holds posts[], cursor, feedMode, pendingReactions, and newPostQueue. Every other component receives data via props and communicates back via callbacks.

The architecture has three key structural decisions:

VirtualList wraps PostCard. The feed doesn't render PostCards directly — it delegates to VirtualList, which decides which PostCards to mount based on scroll position. This separation means virtualization is an optimization layer you can add or remove without touching PostCard.

NewPostBanner is a sibling, not a child of VirtualList. New posts queue in Feed's state. The banner renders the queue count. When clicked, Feed prepends the queue to the post list and clears it. The banner never touches VirtualList — they're independent views of Feed's state.

PostCard fires callbacks upward. When a user taps the heart, PostCard fires onReact(postId, type) — it doesn't make the API call itself. Feed handles the optimistic update, the network request, and the potential rollback. Centralizing mutations in the state owner prevents inconsistent state across components.

Play the “Like (failure)” scenario to see the rollback flow — the most architecturally revealing moment. The UI updates, the server rejects, and the state owner reverses the mutation.

04 — First Render

Eight posts on the right. Simple flat list, all text-only, no scroll handling. Every post has the same shape: avatar, name, content, reaction counts.

TS
1function Feed({ posts }: { posts: FeedItem[] }) {
2 return (
3 <div className="feed">
4 {posts.map(post => (
5 <PostCard key={post.id} post={post} />
6 ))}
7 </div>
8 );
9}

This is the most naive implementation — a .map() over an array. No lazy loading, no virtualization, no type-specific rendering. All metrics are green: 32 DOM nodes, 0 image requests, 60 FPS, 400ms TTI.

The code is deliberately simple because it establishes a baseline. Every optimization from here is measured against these numbers. When something regresses, you'll know exactly which step caused it.

05 — Post Type Polymorphism

Toggle “Enable Post Type Polymorphism” on the right. The feed transforms — some posts now have image placeholders, link preview cards, and poll bar charts. Same data, different rendering.

The widget below shows the four post types and what each renders. The key pattern is a discriminated union:

TS
1function PostCard({ post }: { post: FeedItem }) {
2 return (
3 <article className="post-card">
4 <PostHeader author={post.author} timestamp={post.createdAt} />
5 <p>{post.content}</p>
6
7 {post.type === "image" && <ImageAttachment media={post.media} />}
8 {post.type === "link" && <LinkPreview og={post.linkPreview} />}
9 {post.type === "poll" && <PollResults poll={post.poll} />}
10
11 <ReactionBar reactions={post.reactions} />
12 </article>
13 );
14}

One component, one switch point. Not four separate components (TextPost, ImagePost, LinkPost, PollPost) — that fragments the shared header and reaction bar into four copies. The discriminated union keeps the shared parts shared and varies only the content section.

Notice the colored left borders in the demo: purple for image, teal for link, orange for poll. This is a visual hint to the user about content type — and it makes the feed scannable. Users scroll fast; color coding helps them spot the content type they're looking for without reading.

06 — Infinite Scroll

Toggle “Infinite Scroll” on the right. A sentinel element appears at the bottom of the feed — a dashed line with the label “IntersectionObserver sentinel.”

TS
1const sentinelRef = useRef<HTMLDivElement>(null);
2
3useEffect(() => {
4 const observer = new IntersectionObserver(
5 ([entry]) => {
6 if (entry.isIntersecting && !loading && cursor) {
7 fetchNextPage(cursor);
8 }
9 },
10 { rootMargin: "200px" }
11 );
12 if (sentinelRef.current) observer.observe(sentinelRef.current);
13 return () => observer.disconnect();
14}, [loading, cursor]);

The sentinel is a zero-height <div> placed after the last post. When IntersectionObserver detects it entering the viewport (plus a 200px buffer), it triggers the next page fetch. This fires exactly once per scroll threshold crossing — not once per scroll event.

Why not a scroll event handler? A scroll handler fires 60+ times per second during a gesture. Each invocation calculates scrollHeight - scrollTop - clientHeight and compares to a threshold. That's a forced layout recalculation per frame. IntersectionObserver runs on the compositor thread — zero main-thread cost.

The rootMargin: "200px" means the fetch starts 200px before the user actually reaches the bottom. On a fast connection, the next page arrives before the user sees the sentinel. The feed feels infinite.

The widget below shows a miniature diagram of the viewport, content, and sentinel position. The sentinel is the trigger point — everything above it is content, everything below is unfetched.

07 — Optimistic Likes

Toggle “Optimistic Likes” on the right. Now click the heart on any post. Watch it turn red and the count increment instantly — before any “server” response.

Click a post ending in 3 or 7. The heart turns red, then after ~800ms, the server “rejects” — the heart shakes and reverts, and the count decrements. That shake animation is intentional feedback: something went wrong, here's the rollback.

TS
1function toggleLike(postId: string) {
2 // 1. Optimistic: update UI immediately
3 setPosts(prev => prev.map(p =>
4 p.id === postId
5 ? { ...p, liked: !p.liked, likes: p.likes + (p.liked ? -1 : 1) }
6 : p
7 ));
8 setPending(prev => new Set(prev).add(postId));
9
10 // 2. Server: confirm or reject
11 const res = await fetch(`/api/posts/${postId}/react`, { method: 'POST' });
12 setPending(prev => { const next = new Set(prev); next.delete(postId); return next; });
13
14 if (!res.ok) {
15 // 3. Rollback: reverse the optimistic update
16 setPosts(prev => prev.map(p =>
17 p.id === postId
18 ? { ...p, liked: !p.liked, likes: p.likes + (p.liked ? 1 : -1) }
19 : p
20 ));
21 setFailed(prev => new Set(prev).add(postId));
22 }
23}

The three states are: optimistic (heart red, count +1, pending dot), confirmed (pending clears, UI stays as-is), rolled back (heart reverts, count -1, shake animation).

The widget below shows this state machine visually. The optimistic path means the user never waits for a network round-trip to see their action reflected. The rollback path means the UI always corrects itself when the server disagrees. Both paths are necessary — optimistic without rollback creates ghost state.

08 — Real-time New Posts

Toggle “Real-time New Posts (SSE)” on the right. After a few seconds, a blue banner appears at the top: “1 new post.” Wait longer — it updates: “2 new posts,” “3 new posts.” Click the banner. The new posts slide into the feed from the top.

This is the queue-and-banner pattern. New posts from the SSE stream don't auto-insert into the feed — they queue silently until the user chooses to see them.

TS
1// SSE connection
2const eventSource = new EventSource('/api/feed/updates?since=' + cursor);
3eventSource.onmessage = (event) => {
4 const newPost = JSON.parse(event.data);
5 setNewPostQueue(prev => [...prev, newPost]);
6};
7
8// Banner click handler
9function insertNewPosts() {
10 setPosts(prev => [...newPostQueue, ...prev]);
11 setNewPostQueue([]);
12}

Why not auto-insert? If the user is reading post #5 and three posts suddenly appear above it, their scroll position shifts. Post #5 is now post #8. The user loses their place. For a feed you're passively scrolling, this is annoying. For a feed you're reading carefully, it's hostile.

The banner is a contract with the user: “New content exists. You decide when to see it.” Twitter uses this pattern. Instagram uses this pattern. Every feed that respects reading position uses this pattern.

The SSE connection (shown as a green pulsing dot in the widget) is persistent — one HTTP connection, server pushes events as they happen. Unlike polling (one request every N seconds), SSE sends data only when there's something to send. Unlike WebSocket, it's unidirectional — the client doesn't need to send data upstream through this channel.

09 — Feed Algorithm

Toggle “Feed Algorithm” on the right, then switch between Chronological and Engagement Ranked. Watch the posts reorder. In ranked mode, each post shows an engagement score badge (⚡).

TS
1const score = (likes * 1.0 + comments * 2.5 + shares * 3.0)
2 / Math.sqrt(minutesAgo + 1);

The formula is simple: weight each engagement signal, sum them, divide by the square root of age. Newer posts with high engagement rank highest. Old posts with moderate engagement decay. This is a toy version of what production feeds use — real algorithms factor in user affinity, content type preference, and diversity constraints.

Why comments × 2.5 and shares × 3.0? Comments require more effort than likes — a user who comments is more engaged. Shares amplify reach — a shared post generates secondary impressions. The weights are empirical, tuned to a specific product's engagement goals.

The ranking table in the widget shows the top 5 posts with their individual signal values and computed scores. Switch back to chronological and the table disappears — chronological feeds don't need scores because the sort key is just createdAt.

The hard question isn't “how do you sort” — it's “when do you re-sort.” If the user likes a post while in ranked mode, its score changes. Do you immediately re-rank the feed? That would move the post the user just interacted with, which is disorienting. Production feeds freeze the sort order within a session and re-rank only on refresh.

10 — Virtualization

Toggle “Virtualization (DOM Recycling)” on the right. Watch the DOM metric drop from hundreds to about 48.

The virtual window strip in the widget shows every post as a dot. Filled dots are real DOM nodes. Empty dots are height placeholders in an offset table. The window is tiny compared to the total.

TS
1const offsets = computeOffsets(posts, estimatedRowHeight);
2const startIdx = binarySearch(offsets, scrollTop - buffer);
3const endIdx = binarySearch(offsets, scrollTop + viewportHeight + buffer);
4const visiblePosts = posts.slice(startIdx, endIdx);

Virtualization is a rendering optimization, not a data optimization. All posts stay in memory (the state array is intact). Only the visible ones get DOM nodes. When the user scrolls, nodes are recycled — the same <article> element that showed post #3 now shows post #15.

This is independent from infinite scroll. Infinite scroll controls how many posts we fetch. Virtualization controls how many posts we render. You can have one without the other, and a production feed uses both.

The offset table is cheap: 50K entries × 8bytes = 400KB. The binary search runs in O(log n). For 50K posts, that's 16 comparisons to find the first visible post. Scroll performance stays at 60 FPS regardless of feed length.

11 — Skeleton Loading

Toggle “Skeleton Loading” on the right. Three shimmer cards appear at the bottom of the feed — avatar circle, text lines pulsing.

Skeletons aren't just “nicer loading spinners.” They serve two distinct purposes:

Layout reservation. Without skeletons, new posts pop into existence and push everything below them down. With skeletons, the space is already occupied. The transition from shimmer to content is a replacement, not an insertion. CLS stays at zero.

Cognitive priming. The shimmer card's shape — circle on the left, three lines on the right — matches the actual post layout. The user's brain recognizes “a post is coming” before the data arrives. This reduces perceived wait time even when the actual wait time is identical.

The widget shows a side-by-side comparison: a spinner (“Loading...”) vs skeleton cards. The spinner communicates “wait.” The skeleton communicates “here's what's coming, in this shape, at this position.”

TS
1function SkeletonCard() {
2 return (
3 <div className="skeleton-card">
4 <div className="skeleton-avatar" />
5 <div className="skeleton-lines">
6 <div className="skeleton-line" style={{ width: "40%" }} />
7 <div className="skeleton-line" style={{ width: "90%" }} />
8 <div className="skeleton-line" style={{ width: "65%" }} />
9 </div>
10 </div>
11 );
12}

The line widths aren't random — they approximate real content distribution. The first line (40%) is the author name. The second (90%) is the first sentence. The third (65%) is the tail. This isn't precise, but it creates a plausible preview.

12 — Rich Content Embedding

Toggle “Rich Content Embedding” on the right. Link posts now show OG preview cards with domain, title, and description. Poll posts show vote bars with percentages.

Link previews require fetching Open Graph metadata from the linked URL — that's a server-side operation (the client can't fetch arbitrary URLs due to CORS). The server extracts og:title, og:description, and og:image when the post is created and stores them as OGData on the FeedItem.

TS
1{post.type === "link" && post.linkPreview && (
2 <div className="link-preview" style={{ borderColor: post.linkPreview.color }}>
3 <span className="link-domain">{post.linkPreview.domain}</span>
4 <h4>{post.linkPreview.title}</h4>
5 <p>{post.linkPreview.description}</p>
6 </div>
7)}

The colored left border on link previews isn't decorative — it's a scannable signal. Users scrolling fast can identify link posts by color without reading the domain text.

Poll results use horizontal bars with percentage labels. The bars animate their width on mount — a 600ms ease transition from 0% to the final width. This draws the eye and helps users compare options at a glance.

The embedding layer is additive — it renders inside the existing PostCard, not instead of it. If the OG data fails to load (server couldn't reach the URL, metadata is missing), the post degrades gracefully to a plain text link. Embedding enriches; it never blocks.

13 — Accessibility

The feed is now semantically annotated for assistive technology.

HTML
1<div role="feed" aria-busy="false" aria-label="News feed">
2 <article aria-posinset="1" aria-setsize="200" aria-labelledby="post-1-author">
3 <!-- PostCard content -->
4 </article>
5</div>

role="feed" tells screen readers this is a scrollable feed of articles. The reader can use feed-specific shortcuts — in NVDA, F moves to the next article; in VoiceOver, the rotor groups articles.

aria-busy="true" during loading prevents the screen reader from announcing intermediate states. When new posts are loading, aria-busy goes true. When they arrive, it goes false and the reader announces the new content count.

aria-posinset and aria-setsize communicate position: “Article 1 of 200.” Without these, a screen reader user has no sense of how far through the feed they are.

aria-live="polite" on the new post banner means the screen reader will announce “3 new posts” when the banner updates — but it waits until the user is idle, not interrupting their current reading.

The reaction button uses aria-pressed to communicate the toggle state. A screen reader announces “Like button, pressed” or “Like button, not pressed.” Without aria-pressed, the user can't distinguish liked from unliked posts.

14 — Error Handling

Toggle “Simulate Errors” on the right. Some posts turn into red error cards with retry buttons. Click retry — the post recovers.

The error state machine in the widget shows the full lifecycle:

1pending → loading → loaded
2
3 failed → loading (retry) → loaded

Post load failure is the most common error. Individual posts fail due to CDN errors, deleted content, or network timeouts. Each failed post shows a retry button independently — not a global error banner. The user can dismiss or retry individual failures without losing the rest of the feed.

Reaction failure (from step 7) is a different error class. It doesn't show as a red card — it manifests as a heart shake and count rollback. The post itself is fine; only the mutation failed. Different error types need different recovery UX.

SSE disconnection (not simulated in the demo, but essential in production) triggers a reconnection strategy. The browser's built-in EventSource automatically reconnects with exponential backoff. On reconnect, it sends the Last-Event-Id header so the server can replay missed events.

TS
1async function fetchWithRetry(url: string, attempt = 0): Promise<Response> {
2 try {
3 return await fetch(url);
4 } catch (err) {
5 if (attempt < 3) {
6 await sleep(1000 * Math.pow(2, attempt));
7 return fetchWithRetry(url, attempt + 1);
8 }
9 throw err;
10 }
11}

The exponential backoff (1s 2s 4s) prevents retry storms. If the CDN is temporarily down, 50 posts retrying every 100ms creates 500 requests per second — backoff reduces that to ~15 over 7 seconds.

15 — Scaling to 50K

Drag the slider on the right from 50 to 50K. Watch the CWV gauges change color.

At 500 posts: the feed works smoothly with virtualization. 48 DOM nodes regardless of total. Infinite scroll fetches 20 per page. No noticeable degradation.

At 5K posts: the offset table for virtualization is 40KB — negligible. The real concern is the in-memory post array. 5K FeedItems at ~200bytes each is 1MB of JSON in state. Acceptable, but growing.

At 50K posts: memory becomes the bottleneck. 50K × 200bytes = 10MB of feed data in memory. If the user scrolls through 30K posts with images, decoded image data could reach 500+ MB. The fix: an LRU eviction cache for decoded images, and lazy disposal of FeedItems beyond a threshold (keep the last 5K in memory, re-fetch older posts if the user scrolls back).

Cursor vs offset pagination. Offset pagination (?page=5&limit=20) breaks when new posts are inserted — post #100 becomes post #101, and page 5 shows a duplicate. Cursor pagination (?after=post_99) is stable under insertions — “give me everything after this post” always returns the correct next page regardless of what was inserted above.

CDN images. Every image post's media URL should point to a CDN, not the origin server. Cache-Control: public, max-age=86400, immutable for processed images. The feed's LCP element is typically the first image post — serve it from a nearby edge node.

Code splitting. The feed algorithm engine (ranking formula, diversity constraints) is only needed in ranked mode. Code-split it behind a dynamic import so chronological feeds don't pay the parse cost.

Scope checklist

Scope
Toggle items to define scope