Frontend System Design: News Feed (Facebook / Twitter)

interviewMarch 28, 2026ยท 7 min read

The news feed is one of the most common frontend system design questions at FAANG and growth-stage companies. It's a deceptively deep problem โ€” what looks like "a list of posts" involves infinite scroll, real-time updates, optimistic rendering, virtualization, and media performance.

This article walks through a full RADIO-framework answer. If you're unfamiliar with RADIO, start here.

R โ€” Requirements

Functional requirements:

  • Display a scrollable feed of posts from people the user follows
  • Each post shows: author avatar + name, timestamp, text content, media (image/video), like/comment/share counts and actions
  • Infinite scroll โ€” more posts load automatically as the user scrolls down
  • New posts appear at the top in real-time (or with a "N new posts" prompt)
  • Users can like/comment/share a post without leaving the feed
  • Clicking a post opens a detail view

Non-functional requirements:

  • Initial feed loads in under 2 seconds on a 4G connection
  • Scroll should stay at 60fps โ€” no jank from layout thrash or heavy renders
  • Works on mobile and desktop
  • Accessible to keyboard and screen reader users
  • Graceful degradation when offline

Out of scope:

  • Feed ranking algorithm (that's backend)
  • Posting new content
  • Notifications
  • Stories / Reels

A โ€” Architecture

News Feed architecture โ€” VirtualFeed, NewPostsBanner, useFeed and useRealtime hooks, Server

The feed is composed of a few clear layers:

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚                    <FeedPage>                          โ”‚
โ”‚  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”  โ”‚
โ”‚  โ”‚           <NewPostsBanner>                      โ”‚  โ”‚
โ”‚  โ”‚   "5 new posts โ€” click to refresh"              โ”‚  โ”‚
โ”‚  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜  โ”‚
โ”‚  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”  โ”‚
โ”‚  โ”‚              <VirtualFeed>                      โ”‚  โ”‚
โ”‚  โ”‚  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”  โ”‚  โ”‚
โ”‚  โ”‚  โ”‚  <PostCard> (ร—N โ€” only visible rendered)  โ”‚  โ”‚  โ”‚
โ”‚  โ”‚  โ”‚    <PostHeader>  avatar, name, time       โ”‚  โ”‚  โ”‚
โ”‚  โ”‚  โ”‚    <PostBody>    text + media             โ”‚  โ”‚  โ”‚
โ”‚  โ”‚  โ”‚    <PostActions> like, comment, share     โ”‚  โ”‚  โ”‚
โ”‚  โ”‚  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜  โ”‚  โ”‚
โ”‚  โ”‚  <LoadMoreSentinel> (IntersectionObserver)      โ”‚  โ”‚
โ”‚  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜  โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
         โ”‚
         โ”œโ”€โ”€ useFeed() โ€” fetch, pagination, cache
         โ””โ”€โ”€ useRealtime() โ€” WebSocket / SSE new posts

Components:

  • FeedPage โ€” root, composes everything
  • NewPostsBanner โ€” "N new posts" prompt (appears on real-time update)
  • VirtualFeed โ€” windowed list, renders only visible cards
  • PostCard โ€” single post unit
  • PostHeader โ€” avatar, display name, username, timestamp
  • PostBody โ€” text with @mention / #hashtag parsing, image/video
  • PostActions โ€” like, comment, share buttons with counts
  • LoadMoreSentinel โ€” invisible div at list bottom; triggers next page load

Data layer:

  • useFeed() โ€” manages paginated fetch, caching, merging new posts
  • useRealtime() โ€” WebSocket/SSE subscription, buffers new post IDs
  • usePostActions() โ€” handles like/comment mutations with optimistic updates

D โ€” Data Model

API Response (paginated feed)

type Author = {
  id: string;
  displayName: string;
  username: string;         // @handle
  avatarUrl: string;
  isVerified: boolean;
};

type Media = {
  type: 'image' | 'video' | 'gif';
  url: string;
  width: number;
  height: number;
  altText?: string;
  thumbnailUrl?: string;    // For video
  durationSeconds?: number; // For video
};

type Post = {
  id: string;
  author: Author;
  content: string;          // Raw text; parse @mentions/#hashtags client-side
  media?: Media[];
  likeCount: number;
  commentCount: number;
  shareCount: number;
  hasLiked: boolean;        // Current user's state
  createdAt: string;        // ISO 8601
  updatedAt: string;
};

type FeedResponse = {
  posts: Post[];
  nextCursor: string | null;  // Cursor-based pagination (not page numbers)
};

Why cursor-based pagination? Offset pagination (?page=2) breaks when new posts are inserted at the top โ€” you get duplicates or skipped posts. Cursors (server-side pointer to "fetch posts after this ID/timestamp") are stable.

Client State

type FeedState = {
  posts: Post[];              // Ordered list (newest first after initial load)
  nextCursor: string | null;
  status: 'idle' | 'loading' | 'loadingMore' | 'error';
  pendingPosts: Post[];       // Real-time arrivals, not yet shown
  hasMore: boolean;
};

Normalisation

For a feed where users can update posts (likes, comments) without refetching, normalise by ID:

type FeedStore = {
  postIds: string[];                  // Ordered feed
  postsById: Record<string, Post>;    // Entity map for O(1) updates
};

When a like action resolves, update postsById[postId].likeCount and the entire feed re-renders correctly.

I โ€” Interface

Feed API

// Initial load
GET /api/feed?limit=20
โ†’ FeedResponse

// Load more (infinite scroll)
GET /api/feed?limit=20&cursor={nextCursor}
โ†’ FeedResponse

// Real-time: new post IDs only (lightweight)
WebSocket: wss://api.example.com/feed/live
โ†’ { type: 'new_posts', postIds: string[] }

// Fetch specific posts by ID (after WS notification)
GET /api/posts?ids=id1,id2,id3
โ†’ { posts: Post[] }

Like Action API

POST /api/posts/{postId}/like
DELETE /api/posts/{postId}/like
โ†’ { likeCount: number, hasLiked: boolean }

Component Props

interface PostCardProps {
  post: Post;
  onLike: (postId: string) => void;
  onComment: (postId: string) => void;
  onShare: (postId: string) => void;
  onAuthorClick: (authorId: string) => void;
}

interface FeedProps {
  userId: string;
}

O โ€” Optimisations

1. Infinite Scroll with IntersectionObserver

Avoid scroll event listeners โ€” they fire on every scroll tick and cause jank. Use IntersectionObserver instead:

const sentinelRef = useRef<HTMLDivElement>(null);

useEffect(() => {
  const observer = new IntersectionObserver(
    ([entry]) => {
      if (entry.isIntersecting && hasMore && status !== 'loadingMore') {
        loadMore();
      }
    },
    { rootMargin: '200px' }  // Start loading 200px before the sentinel
  );
  if (sentinelRef.current) observer.observe(sentinelRef.current);
  return () => observer.disconnect();
}, [hasMore, status]);

// In JSX:
<div ref={sentinelRef} aria-hidden="true" />

The 200px rootMargin means we start fetching before the user actually hits the bottom โ€” feels seamless.

2. Virtualisation (Windowed Rendering)

A feed of 200+ posts with images will kill scroll performance โ€” all those DOM nodes and images exist in memory even when off-screen.

Windowing only renders what's visible:

import { useVirtualizer } from '@tanstack/react-virtual';

const rowVirtualizer = useVirtualizer({
  count: posts.length,
  getScrollElement: () => scrollRef.current,
  estimateSize: () => 300,    // Estimated post height in px
  overscan: 5,                 // Render 5 extra items above/below viewport
});

This keeps the DOM to ~15 nodes regardless of feed length. Critical for long sessions.

Challenge: Posts have variable heights (text-only vs. image vs. video). Use measureElement to measure actual rendered heights and update estimates dynamically.

3. Real-Time Updates Without Flooding the Feed

Inserting new posts directly at the top as they arrive is disorienting โ€” the content the user is reading jumps down. Instead:

  1. Buffer incoming post IDs in pendingPosts
  2. Show a banner: "5 new posts"
  3. On click (or on scroll-to-top): prepend them and clear the buffer
// On WebSocket message
wsRef.current.onmessage = ({ data }) => {
  const { postIds } = JSON.parse(data);
  // Fetch the actual posts
  fetchPostsByIds(postIds).then(newPosts => {
    dispatch({ type: 'BUFFER_NEW_POSTS', posts: newPosts });
  });
};

// Banner
{pendingPosts.length > 0 && (
  <button className="new-posts-banner" onClick={showPendingPosts}>
    โ†‘ {pendingPosts.length} new {pendingPosts.length === 1 ? 'post' : 'posts'}
  </button>
)}

This is what Twitter/X does โ€” much better UX than auto-inserting.

4. Optimistic UI for Likes

A like action should feel instant. Don't wait for the server:

function handleLike(postId: string) {
  // Optimistic update
  dispatch({ type: 'TOGGLE_LIKE', postId });

  // Server sync
  api.toggleLike(postId).catch(() => {
    // Revert on failure
    dispatch({ type: 'TOGGLE_LIKE', postId });
    showToast('Failed to like โ€” please try again');
  });
}

The UI updates immediately; on error, it silently reverts. Users rarely notice a brief flicker; they always notice a 300ms delay.

5. Image Performance

Feed images are the single biggest performance cost. Key techniques:

Lazy loading: Only load images when they're about to enter the viewport.

<img src={media.url} loading="lazy" alt={media.altText ?? ''} />

Correct dimensions: Always set width and height to prevent layout shift (CLS). Use aspect-ratio boxes:

.post-media {
  aspect-ratio: 16 / 9;
  background: var(--skeleton-color);  /* Show while loading */
}

Responsive images: Serve appropriate resolution per screen density:

<img
  srcSet={`${media.url}?w=400 400w, ${media.url}?w=800 800w`}
  sizes="(max-width: 600px) 400px, 800px"
  alt={media.altText ?? ''}
/>

Blur-up placeholders: Show a tiny blurred thumbnail while the full image loads (same technique used by Medium and Gatsby Image).

6. Video Autoplay Strategy

For video posts:

  • Autoplay only when in viewport โ€” use IntersectionObserver, mute by default
  • Pause when scrolled out โ€” don't burn mobile data in the background
  • Preload metadata only (preload="metadata") โ€” get duration/dimensions without downloading the full video
const videoRef = useRef<HTMLVideoElement>(null);

useEffect(() => {
  const observer = new IntersectionObserver(
    ([entry]) => {
      if (entry.isIntersecting) videoRef.current?.play();
      else videoRef.current?.pause();
    },
    { threshold: 0.5 }  // 50% visible before playing
  );
  if (videoRef.current) observer.observe(videoRef.current);
  return () => observer.disconnect();
}, []);

7. Accessibility

Feed list semantics:

<main aria-label="News Feed">
  <ul role="feed" aria-busy={status === 'loading'}>
    {posts.map(post => (
      <li key={post.id} aria-posinset={index + 1} aria-setsize={-1}>
        <article>
          {/* post content */}
        </article>
      </li>
    ))}
  </ul>
</main>

role="feed" is the ARIA role specifically designed for infinite-scroll content. aria-setsize="-1" tells screen readers the total count is unknown.

Like button:

<button
  aria-label={hasLiked ? 'Unlike post' : 'Like post'}
  aria-pressed={hasLiked}
  onClick={() => onLike(post.id)}
>
  <HeartIcon /> {likeCount}
</button>

Timestamps: Always render timestamps as <time datetime="2026-03-28T12:00:00Z">2h ago</time> โ€” screen readers can speak the full date while users see the relative format.

8. Skeleton Screens

Instead of a spinner, show skeleton cards on initial load โ€” they communicate layout before content arrives and feel faster:

{status === 'loading' && Array.from({ length: 5 }).map((_, i) => (
  <PostSkeleton key={i} />
))}

A PostSkeleton is just CSS animated grey boxes matching the post shape. No JS needed.

9. Offline & Error Handling

StateWhat to show
No network on initial loadCached feed from previous session (if available) + offline banner
Network lost mid-scrollStop fetching, show "You're offline" toast, resume on reconnect
API error on initial loadFull-page error state with retry button
API error on load-moreInline error below the last post with retry
Like action failsOptimistic revert + toast notification

10. Stale-While-Revalidate Caching

Cache the first page of the feed in localStorage or IndexedDB. On next visit:

  1. Show cached posts immediately (no blank screen)
  2. Fetch fresh data in background
  3. Merge/replace when it arrives

This makes the feed feel instant on repeat visits.

Summary

A complete news feed system design covers:

  • Requirements: infinite scroll, real-time updates, like/comment actions, media posts, a11y, mobile
  • Architecture: VirtualFeed + PostCard + NewPostsBanner, data in useFeed + useRealtime hooks, normalised post store
  • Data model: cursor-based pagination, normalised entity map, real-time post buffering, hasLiked per-user state
  • Interface: GET /api/feed?cursor=, WebSocket for new post IDs, POST/DELETE /api/posts/{id}/like
  • Optimisations: IntersectionObserver infinite scroll, virtualisation for 60fps, buffered real-time updates, optimistic likes, lazy images with aspect-ratio, video autoplay on viewport, ARIA role="feed", skeleton screens, offline cache

The Series: Frontend System Design Interview Questions


ยฉ 2024, Built with Gatsby