Skip to content

System Design: News Feed

advanced35 min read

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.

Mental Model

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

  1. View feed — users see a reverse-chronological (or algorithmically ranked) stream of posts from people they follow
  2. Create post — text, images (single or carousel), and video upload with preview
  3. Interact with posts — like, comment, share, bookmark
  4. Infinite scroll — load more content as the user scrolls down, no pagination buttons
  5. Real-time updates — new posts and reaction counts appear without manual refresh
  6. Comment threads — nested replies with lazy-loaded expansion
  7. Media rendering — responsive images, auto-playing muted video in viewport, video player with controls

Non-Functional Requirements

RequirementTargetWhy
LCPUnder 2.5sGoogle's "good" threshold. Feed content must be visible fast.
INPUnder 200msLike buttons, comment input, scroll — all must feel instant.
CLSUnder 0.1No layout shifts when images load or new posts arrive.
Offline readingService worker cacheUsers on flaky connections should see cached feed.
AccessibilityWCAG 2.1 AAKeyboard navigation, screen reader announcements, focus management.
InternationalizationRTL + locale-aware datesArabic, Hebrew layouts. Relative timestamps localized.
Bundle sizeUnder 200KB initial JSFeed page loads fast even on 3G.
Quiz
In a system design interview, what should you do FIRST when asked to design a news feed?

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.

Partial hydration wins

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:

  • blurDataUrl on 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.
  • currentUserReaction on 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.
  • replyCount on Comment — lets you show "View 12 replies" without fetching all replies upfront. Lazy-load on click.
  • width and height on 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 updatesuseMutation with onMutate gives you instant like/unlike without waiting for the server
Quiz
Why does the Post entity include currentUserReaction instead of fetching reactions separately?

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-window requires fixed or pre-calculated heights. react-virtuoso measures dynamically.
  • Built-in infinite scrollendReached callback 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.
  • blurDataUrl as 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.

Quiz
A user is reading a post mid-feed. A new post arrives via WebSocket. What should the UI do?
Quiz
Why use cursor-based pagination instead of offset-based for a feed?

Putting It All Together

Here is the complete data flow from first request to steady-state reading:

Execution Trace
Initial Request
Browser sends GET /feed-page to Next.js server
SSR begins
Server Fetch
Server calls GET /api/feed?limit=10 and serializes response
First 10 posts fetched server-side
HTML Streaming
Server streams HTML with rendered posts and embedded JSON data
Suspense boundaries stream progressively
First Paint
Browser renders server HTML — user sees feed content
LCP achieved before JS loads
Hydration
React hydrates interactive components (buttons, composer, scroll)
Non-interactive parts skip hydration
TanStack Query Init
Query client seeds cache with SSR data, sets cursor after post 10
No duplicate fetch
WebSocket Connect
Client opens WebSocket for real-time feed events
Subscribes to user's feed channel
Virtuoso Mount
react-virtuoso measures post heights and begins managing the viewport
Only viewport posts in DOM
Scroll + Prefetch
User scrolls down, sentinel triggers next page prefetch
Data arrives before user reaches bottom
Steady State
Virtualized scroll, optimistic interactions, queued real-time updates
Feed runs at 60fps with flat memory

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-live for new posts — the "3 new posts" banner uses aria-live="polite" so screen readers announce it without interrupting current content
  • Image alt text — every image has descriptive alt text from the Media.alt field. Decorative images use alt=""
  • 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-motion to 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> with aria-label="Create a post"
What developers doWhat 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
Key Rules
  1. 1Always start with Requirements before Architecture — scope before structure.
  2. 2SSR the first batch of posts for fast LCP, then hydrate only interactive components.
  3. 3Virtualize the list — only viewport posts should exist in the DOM, regardless of feed length.
  4. 4Use cursor-based pagination — offset pagination breaks with real-time insertions.
  5. 5Optimistic updates for reactions — update the UI immediately, roll back on error, refetch on settle.
  6. 6Queue real-time posts behind a banner — never inject content above the user's reading position.
  7. 7Reserve image space with explicit dimensions and blur placeholders to prevent CLS.
  8. 8TanStack Query for server state — it handles caching, deduplication, pagination, and background refetching out of the box.
  9. 9Accessibility is not optional — keyboard nav, aria-live regions, image alt text, and reduced motion support.
Quiz
What is the primary benefit of embedding currentUserReaction and blurDataUrl in the API response instead of fetching them separately?