Learn the concept
Data Layer Architecture
A social media feed requires cursor-based pagination for data fetching, list virtualization to render only visible items, Intersection Observer for infinite scroll triggers, optimistic updates for interactions (likes/comments), and careful memory management to prevent performance degradation during long scroll sessions.
The news feed is the second most frequently asked frontend system design question after autocomplete. It tests your understanding of data fetching, rendering performance, user interactions, and resource management at scale.
Functional Requirements:
Non-Functional Requirements:
Why cursor-based instead of offset-based?
Offset pagination (?page=2&limit=20) breaks when new posts are added:
Cursor pagination uses a stable pointer:
GET /api/feed?cursor=post_abc123&limit=20
Response:
{
"posts": [...],
"nextCursor": "post_xyz789",
"hasMore": true
}The cursor is typically an encoded timestamp + post ID for deterministic ordering. It is stable regardless of insertions or deletions.
Instead of listening to scroll events (which fire 60+ times per second and cause jank), use Intersection Observer:
<div> at the bottom of the feedThis is more performant than scroll listeners because:
This is the most critical performance optimization. Without virtualization, a feed with 500 posts creates 500+ DOM nodes, causing:
Virtualization renders only the items visible in the viewport plus a small overscan buffer (e.g., 5 items above and below). As the user scrolls, items are recycled.
Libraries:
For a social feed with variable-height posts (text-only vs image vs video), use dynamic height virtualization — measure each item after render and cache the measurement.
When a user clicks "Like":
This makes the app feel instant. The key implementation details:
Lazy loading images:
loading="lazy" on <img> elements (native browser support)srcset for different screen densitiesVideo handling:
Show skeleton placeholders that match the layout of real posts:
When new posts arrive via real-time (SSE/WebSocket):
aria-live="polite" for screen readersDuring long scroll sessions (user scrolls through 500+ posts):
role="feed" with aria-label="News Feed"role="article" with aria-labelledby pointing to the author namearia-live="polite" region saying "20 more posts loaded"aria-label with current state: "Like post by Alice, currently liked"import { useEffect, useRef, useCallback, useState } from 'react';
interface Post {
id: string;
author: string;
content: string;
imageUrl?: string;
likes: number;
isLiked: boolean;
createdAt: string;
}
interface FeedPage {
posts: Post[];
nextCursor: string | null;
hasMore: boolean;
}
export function useInfiniteFeed() {
const [posts, setPosts] = useState<Post[]>([]);
const [cursor, setCursor] = useState<string | null>(null);
const [hasMore, setHasMore] = useState(true);
const [isLoading, setIsLoading] = useState(false);
const sentinelRef = useRef<HTMLDivElement>(null);
const observerRef = useRef<IntersectionObserver | null>(null);
const fetchNextPage = useCallback(async () => {
if (isLoading || !hasMore) return;
setIsLoading(true);
try {
const url = cursor
? `/api/feed?cursor=${cursor}&limit=20`
: '/api/feed?limit=20';
const res = await fetch(url);
const data: FeedPage = await res.json();
setPosts((prev) => [...prev, ...data.posts]);
setCursor(data.nextCursor);
setHasMore(data.hasMore);
} catch (error) {
console.error('Failed to fetch feed:', error);
} finally {
setIsLoading(false);
}
}, [cursor, hasMore, isLoading]);
// Set up Intersection Observer on the sentinel element
useEffect(() => {
// Disconnect previous observer
observerRef.current?.disconnect();
observerRef.current = new IntersectionObserver(
(entries) => {
// When sentinel is visible, load next page
if (entries[0]?.isIntersecting) {
fetchNextPage();
}
},
{
// Start loading when sentinel is 200px from viewport
rootMargin: '200px',
threshold: 0,
}
);
if (sentinelRef.current) {
observerRef.current.observe(sentinelRef.current);
}
return () => observerRef.current?.disconnect();
}, [fetchNextPage]);
// Optimistic like toggle
const toggleLike = useCallback(async (postId: string) => {
setPosts((prev) =>
prev.map((post) =>
post.id === postId
? {
...post,
isLiked: !post.isLiked,
likes: post.isLiked ? post.likes - 1 : post.likes + 1,
}
: post
)
);
try {
await fetch(`/api/posts/${postId}/like`, { method: 'POST' });
} catch {
// Rollback on failure
setPosts((prev) =>
prev.map((post) =>
post.id === postId
? {
...post,
isLiked: !post.isLiked,
likes: post.isLiked ? post.likes - 1 : post.likes + 1,
}
: post
)
);
}
}, []);
return { posts, isLoading, hasMore, sentinelRef, toggleLike };
}Facebook, Twitter/X, Instagram, and LinkedIn all use infinite scroll feeds with cursor-based pagination, virtualization, and optimistic interactions. Each has unique challenges like mixed media types, retweets/shares, and algorithm-ranked content.
Amazon and Etsy use infinite scroll or load-more patterns for search results, with lazy-loaded product images, skeleton placeholders, and cursor-based pagination to handle millions of products.
Reddit, Hacker News, and Google News use feed patterns with mixed content types, collapsible comments, and real-time score updates, requiring careful memory management during long browsing sessions.
Build a Pinterest-style masonry image gallery using the Unsplash API with cursor pagination, Intersection Observer for infinite scroll, lazy-loaded images with blur-up placeholders, and virtualized rendering.
Create a full social media feed with cursor-based pagination, virtualized rendering, optimistic like/comment interactions, a 'new posts available' indicator, pull-to-refresh, and skeleton loading states.
Uses cursor-based pagination for the timeline with virtualized rendering. Their virtual scroller handles variable-height tweets with images, videos, quote tweets, and thread indicators while maintaining smooth 60fps scrolling
Implements aggressive image lazy loading and video autoplay based on visibility. Their feed uses a custom virtual scroller optimized for mixed media content with placeholder shimmer animations