System Design: News Feed
The News Feed Problem
If you get asked to "design a news feed" in a frontend system design interview, the interviewer is testing whether you can think across every layer: component architecture, data flow, API contracts, rendering performance, and user experience at scale. This is the bread-and-butter question at Meta, LinkedIn, and Twitter interviews for a reason — a news feed touches every hard problem in frontend engineering.
We are going to walk through this end-to-end using the RADIO framework. By the end, you will have a production-ready answer that would pass a staff-level interview loop.
Think of a news feed like a newspaper printing press. Raw content (posts, images, videos) arrives from many sources. The press must assemble it into pages (rendering), decide what goes above the fold (prioritization), print only what the reader can see right now (virtualization), and continuously roll in breaking news (real-time updates) — all while the reader keeps flipping pages (infinite scroll). If any stage is slow, the reader walks away.
R — Requirements
The first thing you do in any system design interview is nail down the requirements. Do not jump into architecture. Ask questions, define scope, and separate functional from non-functional.
Functional Requirements
- View feed — users see a reverse-chronological (or algorithmically ranked) stream of posts from people they follow
- Create post — text, images (single or carousel), and video upload with preview
- Interact with posts — like, comment, share, bookmark
- Infinite scroll — load more content as the user scrolls down, no pagination buttons
- Real-time updates — new posts and reaction counts appear without manual refresh
- Comment threads — nested replies with lazy-loaded expansion
- Media rendering — responsive images, auto-playing muted video in viewport, video player with controls
Non-Functional Requirements
| Requirement | Target | Why |
|---|---|---|
| LCP | Under 2.5s | Google's "good" threshold. Feed content must be visible fast. |
| INP | Under 200ms | Like buttons, comment input, scroll — all must feel instant. |
| CLS | Under 0.1 | No layout shifts when images load or new posts arrive. |
| Offline reading | Service worker cache | Users on flaky connections should see cached feed. |
| Accessibility | WCAG 2.1 AA | Keyboard navigation, screen reader announcements, focus management. |
| Internationalization | RTL + locale-aware dates | Arabic, Hebrew layouts. Relative timestamps localized. |
| Bundle size | Under 200KB initial JS | Feed page loads fast even on 3G. |
A — Architecture
With requirements locked, let us design the component tree and layout structure.
Component Tree
FeedPage (Server Component — SSR shell)
├── FeedHeader
│ ├── Logo
│ ├── SearchBar
│ └── UserMenu
├── PostComposer (Client Component)
│ ├── AvatarThumbnail
│ ├── TextArea (auto-resize)
│ ├── MediaUploader
│ │ ├── ImagePicker
│ │ └── VideoPicker
│ └── SubmitButton
├── FeedContainer (Client Component — scroll root)
│ ├── NewPostsBanner ("3 new posts — click to see")
│ ├── VirtualizedList
│ │ └── PostCard (repeated)
│ │ ├── PostHeader
│ │ │ ├── AuthorAvatar
│ │ │ ├── AuthorName + Timestamp
│ │ │ └── OptionsMenu (kebab)
│ │ ├── PostContent
│ │ │ ├── TextBlock (with "See more" truncation)
│ │ │ └── MediaRenderer
│ │ │ ├── ImageGallery (single / carousel)
│ │ │ └── VideoPlayer (lazy, muted autoplay)
│ │ ├── ReactionSummary ("42 likes · 7 comments")
│ │ └── InteractionBar
│ │ ├── LikeButton (optimistic)
│ │ ├── CommentButton
│ │ ├── ShareButton
│ │ └── BookmarkButton
│ └── InfiniteScrollSentinel (Intersection Observer trigger)
├── CommentDrawer (Client Component — slide-up panel)
│ ├── CommentThread (recursive)
│ │ ├── CommentItem
│ │ └── ReplyInput
│ └── CommentComposer
└── FeedSkeleton (loading state)
SSR Strategy
The key architectural decision: stream the initial feed from the server, hydrate only interactive parts on the client.
Server renders:
→ FeedPage shell (header, layout, skeleton)
→ First 10 posts (pre-rendered HTML for LCP)
→ Serialized post data embedded as JSON in script tag
Client hydrates:
→ PostComposer (needs keyboard events, file uploads)
→ FeedContainer (needs scroll events, Intersection Observer)
→ InteractionBar (needs click handlers, optimistic updates)
→ CommentDrawer (needs state, WebSocket)
This gives us fast LCP (server-rendered content visible immediately) while keeping interactive parts client-side. The first 10 posts render without JavaScript — users on slow connections see content while the bundle loads.
React Server Components let you send zero JavaScript for static parts like PostHeader and TextBlock. Only components with event handlers ship client code. On a feed with 50 visible posts, this can cut hydration time by 60-70%.
Layout Structure
┌──────────────────────────────────────────────────┐
│ FeedHeader (sticky, 64px) │
├──────────┬───────────────────────┬───────────────┤
│ │ PostComposer │ │
│ Sidebar │ ─────────────────── │ Suggestions │
│ (240px) │ PostCard │ (300px) │
│ │ PostCard │ │
│ - Nav │ PostCard │ - Who to │
│ - Groups │ PostCard │ follow │
│ - Events │ ...infinite scroll │ - Trending │
│ │ InfiniteScrollSent. │ │
├──────────┴───────────────────────┴───────────────┤
│ (Mobile: single column, no sidebars) │
└──────────────────────────────────────────────────┘
On mobile, the layout collapses to a single column. The sidebar becomes a bottom tab bar. The suggestions panel is accessible via a separate tab or a "Discover" section below the feed.
D — Data Model
Getting the data model right is what separates a junior answer from a staff-level one. You need normalized entities, clean TypeScript interfaces, and a clear state management strategy.
Core Entities
interface Post {
id: string
author: User
content: string
media: Media[]
createdAt: string // ISO 8601
editedAt: string | null
reactionCounts: ReactionCounts
commentCount: number
currentUserReaction: ReactionType | null
currentUserBookmarked: boolean
visibility: 'public' | 'friends' | 'private'
}
interface User {
id: string
name: string
handle: string
avatarUrl: string
verified: boolean
}
interface Media {
id: string
type: 'image' | 'video'
url: string
thumbnailUrl: string
blurDataUrl: string // base64 blur placeholder
width: number
height: number
alt: string
duration?: number // video only, in seconds
}
interface Comment {
id: string
postId: string
parentId: string | null // null = top-level comment
author: User
content: string
createdAt: string
reactionCounts: ReactionCounts
replyCount: number
}
interface ReactionCounts {
like: number
love: number
haha: number
wow: number
sad: number
angry: number
}
type ReactionType = keyof ReactionCounts
Why These Exact Fields?
Every field earns its place:
blurDataUrlon Media — prevents CLS. The blur placeholder renders at the correct aspect ratio before the real image loads. Without this, images pop in and push content down.currentUserReactionon Post — enables instant UI feedback. Without embedding this in the post response, you would need a separate API call per post to check if the user liked it. That is N+1 at scale.replyCounton Comment — lets you show "View 12 replies" without fetching all replies upfront. Lazy-load on click.widthandheighton Media — critical for CLS prevention. The browser can reserve exact space before the image downloads.
State Management: TanStack Query
For a news feed, server state dominates. You are not managing complex client-side state — you are caching, paginating, and keeping server data fresh.
interface FeedQueryKey {
type: 'feed'
cursor: string | null
}
interface FeedPage {
posts: Post[]
nextCursor: string | null
hasMore: boolean
}
// TanStack Query infinite query
const useFeed = () => {
return useInfiniteQuery<FeedPage>({
queryKey: ['feed'],
queryFn: ({ pageParam }) =>
fetchFeed({ cursor: pageParam }),
getNextPageParam: (lastPage) => lastPage.nextCursor,
staleTime: 60_000,
gcTime: 5 * 60_000,
})
}
Why TanStack Query over Redux or Zustand?
- Cursor-based pagination is built in via
useInfiniteQuery - Deduplication — multiple components reading the same post do not fire duplicate requests
- Background refetching — stale data is shown instantly while fresh data loads
- Optimistic updates —
useMutationwithonMutategives you instant like/unlike without waiting for the server
I — Interface (API Design)
REST API Contracts
GET /api/feed — Cursor-paginated feed
// Request
GET /api/feed?cursor=abc123&limit=10
// Response 200
interface FeedResponse {
data: Post[]
pagination: {
nextCursor: string | null
hasMore: boolean
}
}
Why cursor pagination over offset? Offset pagination breaks when new posts are inserted at the top. If you are on page 3 (offset 20) and 5 new posts arrive, page 4 starts with duplicates from page 3. Cursors are stable — "give me everything after this specific post ID" always returns the right data regardless of insertions.
POST /api/posts — Create a post
// Request
POST /api/posts
Content-Type: multipart/form-data
{
content: string
media: File[] // images or video
visibility: 'public' | 'friends' | 'private'
}
// Response 201
interface CreatePostResponse {
data: Post
}
POST /api/posts/:id/reactions — React to a post
// Request
POST /api/posts/xyz789/reactions
{ type: "like" }
// Response 200 — returns updated counts
interface ReactionResponse {
data: {
reactionCounts: ReactionCounts
currentUserReaction: ReactionType | null
}
}
// DELETE to remove a reaction
DELETE /api/posts/xyz789/reactions
GET /api/posts/:id/comments — Paginated comments
GET /api/posts/xyz789/comments?cursor=abc&limit=20&parentId=null
interface CommentsResponse {
data: Comment[]
pagination: {
nextCursor: string | null
hasMore: boolean
}
}
WebSocket Interface (Real-Time)
// Client subscribes to feed updates
ws.send(JSON.stringify({
type: 'subscribe',
channel: 'feed',
userId: 'user123'
}))
// Server pushes events
interface FeedEvent {
type: 'new_post' | 'reaction_update' | 'comment_added' | 'post_deleted'
payload: NewPostEvent | ReactionUpdateEvent | CommentEvent | DeleteEvent
}
interface NewPostEvent {
post: Post
}
interface ReactionUpdateEvent {
postId: string
reactionCounts: ReactionCounts
}
interface CommentEvent {
postId: string
comment: Comment
newCommentCount: number
}
The WebSocket does not push full post objects for reaction updates — just the counts. This keeps the payload small and avoids over-fetching.
Component API (PostCard Props)
interface PostCardProps {
post: Post
onReact: (postId: string, type: ReactionType) => void
onComment: (postId: string) => void
onShare: (postId: string) => void
onBookmark: (postId: string) => void
onAuthorClick: (userId: string) => void
isVisible: boolean // from virtualization
}
Why isVisible? Virtualized lists unmount off-screen items, but sometimes you want to pause video playback or cancel image loads without unmounting. The isVisible prop lets PostCard handle visibility transitions gracefully — pause video when scrolled away, resume when scrolled back.
O — Optimizations
This is where you turn a working feed into a production-grade one. Every optimization here has a concrete performance justification.
1. Virtualized List
The single most important optimization. A feed can have thousands of posts, but only 5-8 are visible at any time. Rendering all of them is insane.
import { Virtuoso } from 'react-virtuoso'
function FeedList({ pages }: { pages: FeedPage[] }) {
const allPosts = pages.flatMap((page) => page.posts)
return (
<Virtuoso
data={allPosts}
itemContent={(index, post) => (
<PostCard key={post.id} post={post} />
)}
endReached={loadMore}
overscan={3}
increaseViewportBy={{ top: 200, bottom: 600 }}
components={{
Footer: () => <FeedSkeleton count={3} />,
}}
/>
)
}
Why react-virtuoso over react-window?
- Dynamic item heights — posts have variable content (text length, media, comments).
react-windowrequires fixed or pre-calculated heights.react-virtuosomeasures dynamically. - Built-in infinite scroll —
endReachedcallback replaces manual Intersection Observer setup. - Smooth scroll restoration — navigating away and back preserves exact scroll position.
2. Image Optimization
function MediaRenderer({ media }: { media: Media }) {
return (
<div
style={{
aspectRatio: `${media.width} / ${media.height}`,
}}
>
<img
src={media.url}
alt={media.alt}
width={media.width}
height={media.height}
loading="lazy"
decoding="async"
style={{
backgroundImage: `url(${media.blurDataUrl})`,
backgroundSize: 'cover',
}}
sizes="(max-width: 640px) 100vw, 600px"
srcSet={`
${media.url}?w=400 400w,
${media.url}?w=600 600w,
${media.url}?w=800 800w
`}
/>
</div>
)
}
Three things working together here:
aspectRatio— reserves exact space before the image loads. Zero CLS.blurDataUrlas background — user sees a blurred preview instantly while the full image loads. Perceived performance is dramatically better.srcSet+sizes— browser picks the optimal image size for the device. A mobile user on a 400px-wide screen does not download an 800px image.
3. Optimistic Likes
The like button must feel instant. No waiting for the server.
function useLikeMutation(postId: string) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (type: ReactionType) =>
postReaction(postId, type),
onMutate: async (type) => {
await queryClient.cancelQueries({ queryKey: ['feed'] })
const previousData = queryClient.getQueryData(['feed'])
queryClient.setQueryData(['feed'], (old: InfiniteData<FeedPage>) => ({
...old,
pages: old.pages.map((page) => ({
...page,
posts: page.posts.map((post) =>
post.id === postId
? {
...post,
currentUserReaction: type,
reactionCounts: {
...post.reactionCounts,
[type]: post.reactionCounts[type] + 1,
},
}
: post
),
})),
}))
return { previousData }
},
onError: (_err, _type, context) => {
queryClient.setQueryData(['feed'], context?.previousData)
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['feed'] })
},
})
}
The pattern: optimistically update the cache, roll back on error, refetch on settle. The user sees the like count increment instantly. If the server rejects it, the count rolls back. On success, the background refetch ensures the count is accurate (other users may have liked it in the meantime).
4. New Posts Banner (Non-Disruptive Real-Time)
Never inject new posts at the top of the feed while the user is reading. This is the most annoying UX pattern on the internet — you are mid-sentence and the post jumps down. Instead:
function useNewPostsBanner() {
const [pendingPosts, setPendingPosts] = useState<Post[]>([])
const queryClient = useQueryClient()
useEffect(() => {
const ws = connectToFeedSocket()
ws.onmessage = (event) => {
const data: FeedEvent = JSON.parse(event.data)
if (data.type === 'new_post') {
setPendingPosts((prev) => [data.payload.post, ...prev])
}
if (data.type === 'reaction_update') {
queryClient.setQueryData(['feed'], (old: InfiniteData<FeedPage>) =>
patchReactionCounts(old, data.payload)
)
}
}
return () => ws.close()
}, [queryClient])
const showPendingPosts = () => {
queryClient.setQueryData(['feed'], (old: InfiniteData<FeedPage>) =>
prependPosts(old, pendingPosts)
)
setPendingPosts([])
window.scrollTo({ top: 0, behavior: 'smooth' })
}
return { pendingCount: pendingPosts.length, showPendingPosts }
}
New posts queue up silently. A banner appears: "3 new posts". The user clicks it when ready, the posts prepend, and the feed scrolls to top. Reaction updates, on the other hand, merge silently — incrementing a like count does not disrupt reading.
5. Skeleton Loading
function PostSkeleton() {
return (
<div className="animate-pulse" role="status" aria-label="Loading post">
<div className="flex items-center gap-3 mb-4">
<div className="h-10 w-10 rounded-full bg-current opacity-10" />
<div className="space-y-2">
<div className="h-4 w-32 rounded bg-current opacity-10" />
<div className="h-3 w-20 rounded bg-current opacity-10" />
</div>
</div>
<div className="space-y-2 mb-4">
<div className="h-4 w-full rounded bg-current opacity-10" />
<div className="h-4 w-3/4 rounded bg-current opacity-10" />
</div>
<div className="h-64 w-full rounded-lg bg-current opacity-10" />
</div>
)
}
Skeletons match the exact dimensions of real content. This prevents CLS when the real data loads in. The role="status" and aria-label make skeletons accessible — screen readers announce "Loading post" instead of reading gibberish.
6. Service Worker for Offline
// sw.ts — service worker registration
const CACHE_NAME = 'feed-v1'
const FEED_API = '/api/feed'
self.addEventListener('fetch', (event: FetchEvent) => {
if (event.request.url.includes(FEED_API)) {
event.respondWith(
fetch(event.request)
.then((response) => {
const cloned = response.clone()
caches.open(CACHE_NAME).then((cache) => {
cache.put(event.request, cloned)
})
return response
})
.catch(() => caches.match(event.request))
)
}
})
Strategy: network-first with cache fallback. Always try the network (users want fresh content). If offline, serve the last cached response. The user sees stale posts instead of a blank screen. When connectivity returns, the next scroll triggers a fresh fetch.
7. Prefetching the Next Page
function usePrefetchNextPage(nextCursor: string | null) {
const queryClient = useQueryClient()
useEffect(() => {
if (!nextCursor) return
queryClient.prefetchInfiniteQuery({
queryKey: ['feed'],
queryFn: () => fetchFeed({ cursor: nextCursor }),
})
}, [nextCursor, queryClient])
}
Start fetching the next page of posts before the user reaches the sentinel. By the time they scroll to the bottom, the data is already in cache. Zero perceived loading time between pages.
Why virtualization is non-negotiable
Without virtualization, a feed with 200 posts creates 200+ DOM nodes, each with images, buttons, and text. On a mid-range Android phone, this causes:
- 2000+ DOM nodes — the browser's layout engine recalculates positions on every scroll frame
- Memory usage climbs to 300-500MB — each image decoded in memory, each DOM node allocated on the heap
- Scroll jank — the main thread spends 30ms+ per frame on layout, missing the 16ms budget for 60fps
- GC pauses — V8's garbage collector runs more frequently with more objects, causing visible hitches
Virtualization keeps DOM nodes under 50 regardless of feed length. Only the viewport window plus overscan exists in the DOM. Everything else is unmounted. Memory stays flat. Scroll stays at 60fps.
Putting It All Together
Here is the complete data flow from first request to steady-state reading:
Accessibility Checklist
A feed that is not keyboard-navigable is a broken feed. Here is what you must cover:
- Tab order — users can tab through posts, each post's interaction buttons are focusable in logical order (like → comment → share → bookmark → next post)
aria-livefor new posts — the "3 new posts" banner usesaria-live="polite"so screen readers announce it without interrupting current content- Image alt text — every image has descriptive alt text from the
Media.altfield. Decorative images usealt="" - Video captions — auto-playing videos are muted by default. Captions available via track element
- Reduced motion — skeleton pulse animations and scroll-to-top use
prefers-reduced-motionto disable motion for users who need it - Focus management — when the "New posts" banner is clicked and feed scrolls to top, focus moves to the first new post
- Landmark regions — feed is wrapped in
<main>, sidebar in<nav>, composer in a<form>witharia-label="Create a post"
| What developers do | What they should do |
|---|---|
| Render all posts in the DOM regardless of visibility Without virtualization, 200+ posts create thousands of DOM nodes, causing scroll jank, high memory usage, and GC pauses on mobile devices | Use a virtualized list that only renders viewport posts |
| Use offset-based pagination for the feed API Offset pagination breaks when new posts are inserted at the top — users see duplicate posts or miss content entirely | Use cursor-based pagination with a stable post ID cursor |
| Push new posts directly into the feed while user is reading Injecting content above the viewport causes layout shifts that interrupt reading — one of the most frustrating UX patterns | Queue new posts behind a non-disruptive banner |
| Wait for the server before updating the like count A 200ms round trip on every like tap makes the UI feel sluggish. Optimistic updates make interactions feel instant while the server confirms asynchronously | Use optimistic updates with rollback on error |
| Load full-resolution images for all viewport sizes A mobile user on a 400px screen should not download an 1800px image. srcSet lets the browser pick the right size, saving bandwidth and improving load time | Use srcSet with multiple resolutions and serve WebP/AVIF |
| Ignore aspect ratio and let images load without dimensions Without reserved space, images pop in and push content down, causing CLS. Blur placeholders provide visual feedback while maintaining layout stability | Set explicit width, height, and aspect-ratio with blur placeholders |
- 1Always start with Requirements before Architecture — scope before structure.
- 2SSR the first batch of posts for fast LCP, then hydrate only interactive components.
- 3Virtualize the list — only viewport posts should exist in the DOM, regardless of feed length.
- 4Use cursor-based pagination — offset pagination breaks with real-time insertions.
- 5Optimistic updates for reactions — update the UI immediately, roll back on error, refetch on settle.
- 6Queue real-time posts behind a banner — never inject content above the user's reading position.
- 7Reserve image space with explicit dimensions and blur placeholders to prevent CLS.
- 8TanStack Query for server state — it handles caching, deduplication, pagination, and background refetching out of the box.
- 9Accessibility is not optional — keyboard nav, aria-live regions, image alt text, and reduced motion support.