DevPrep
  • Interview Prep
  • Projects
  • Resources
  • Pricing
  • About Us
Submit Question
DevPrep
  • Pricing
  • About Us
Submit Question
  1. Home
  2. Articles
  3. Frontend Engineering
  4. System Design: Design Facebook.com — A Complete Frontend Architecture Deep Dive
XLinkedInReddit
Frontend Engineering

System Design: Design Facebook.com — A Complete Frontend Architecture Deep Dive

D
DevPrep Team
February 10, 2026·19 min read·276
Table of Contents
  • 📋 Step 1: Requirements Exploration
  • Clarifying Questions I'd Ask
  • What are Functional vs. Non-Functional Requirements?
  • Functional Requirements for Facebook News Feed
  • Functional Requirements (What the system does)
  • Non-Functional Requirements (How well it does it)
  • 🏗️ Step 2: Architecture / High-Level Design
  • Component Architecture
  • Component Responsibilities
  • Rendering Strategy: SSR + CSR Hybrid
  • 📦 Step 3: Data Model / Core Entities
  • Core Entities
  • Why This Data Model Works
  • Client-Side State Structure
  • 🔌 Step 4: Interface Definition (API Design)
  • API Overview
  • REST API Contracts
  • WebSocket Contract (Real-Time Updates)
  • ⚡ Step 5: Optimizations & Deep Dive
  • 5.1 Feed List Performance — Virtualization
  • 5.2 Infinite Scroll with Intersection Observer
  • 5.3 Optimistic Updates for Reactions
  • 5.4 Image Loading Strategy
  • 5.5 Accessibility Deep Dive
  • 5.6 Performance Budget & Monitoring
  • 5.7 How Things Break at Scale
  • 🔑 Summary: What a Great Answer Looks Like

A complete frontend system design walkthrough — the kind of answer that gets you hired at Meta, Google, and top-tier companies.

If you've ever been asked "Design Facebook" in an interview, you know the sheer panic that follows. Facebook is massive — news feed, profiles, messenger, groups, stories, marketplace, notifications. Where do you even begin?

In this guide, I'll walk you through exactly how I'd approach this problem using the RADIO framework (Requirements, Architecture, Data Model, Interface Definition, Optimizations). This is the same framework used by engineers at Meta, Google, and Amazon in real interviews.

We're going to go deep. Not surface-level bullet points — actual code, actual trade-offs, actual production decisions. Let's go.


📋 Step 1: Requirements Exploration

The biggest mistake candidates make? Jumping straight into architecture. Don't. Spend the first few minutes narrowing scope.

Here's how I'd think about it:

Clarifying Questions I'd Ask

Question

Why It Matters

Assumed Answer

Which part of Facebook are we designing?

Facebook has 50+ features. We need focus.

The News Feed — the core experience

What types of posts should we support?

Text-only is simple. Media changes everything.

Text, images, videos, links with previews

What interactions do users have with posts?

Determines component complexity

Like (with reactions), Comment, Share

What pagination UX should we use?

Affects data fetching strategy entirely

Infinite scroll (not numbered pages)

Do we need real-time updates?

WebSocket vs polling decision

Yes — new posts should appear without refresh

Should we support post creation?

Determines if we need a composer component

Yes — text + media upload

What devices should we target?

Responsive design implications

Desktop-first, responsive to mobile

Do we need offline support?

Service worker + caching strategy

Nice-to-have, not core

What are Functional vs. Non-Functional Requirements?

Before we dive into the specific list for Facebook, let’s clarify the two pillars of any system design requirements phase. Think of these as the Product vs. the Engineering perspective.

1. Functional Requirements (The "What")

These define the features. If you were a product manager, what would you put in the user's hands? Functional requirements describe the specific behaviors of the system—the actions a user can take and the responses they expect.

  • Example: "A user can click a 'Like' button to react to a post."

  • Focus: User workflows, UI components, and business logic.

2. Non-Functional Requirements (The "How")

These define the quality attributes and constraints. They don't describe what the system does, but how well it does it. This is where most senior-level architectural decisions are made.

  • Example: "The 'Like' button must respond within 100ms and work even if the user is on a slow 3G connection."

  • Focus: Performance (latency), Scalability, Accessibility, Reliability, and Security.

Rahul Rana’s Perspective: "When I was at Uber, we didn't just ask 'Can the user book a ride?' We asked 'How does this UI behave when the user is on a 2G connection in Mumbai with 1% battery?' That is the level of thinking I expect when you define your non-functional requirements."


Functional Requirements for Facebook News Feed

Based on our scope, we will focus on these core features:

  • Feed Consumption: Users see a scrollable list of posts (text, images, videos) from their network.

  • Infinite Scroll: New content loads automatically as the user reaches the bottom of the viewport.

  • Post Composer: A multi-media input area to create new posts with privacy settings.

  • Interactions: The ability to react (Like), comment in a nested thread, and share posts.

  • Real-time Updates: A notification or "New Posts" toast appears when new content is available in the feed.

  • Stories: A horizontal shelf at the top of the feed for ephemeral media.

Functional Requirements (What the system does)

  1. Browse News Feed — Users see a scrollable list of posts from friends, pages, and groups

  2. Infinite Scroll — More posts load automatically as user scrolls down

  3. Post Interactions — Like (with reaction picker), comment, share

  4. Create Posts — Text, images, videos, with audience selector

  5. Real-time Updates — New posts appear at the top without page refresh

  6. Stories Bar — Horizontal scrollable stories at the top

Non-Functional Requirements (How well it does it)

  1. Performance — Feed loads in under 2 seconds. Scrolling maintains 60fps.

  2. Scalability — Handle millions of concurrent users

  3. Accessibility — Screen reader compatible, keyboard navigable

  4. SEO — Not critical for authenticated feeds, but good for public pages

  5. Offline Resilience — Graceful degradation when network drops

💡 Pro Tip from Rahul: In real interviews, requirements exploration should take about 10-15% of your time. The interviewer wants to see that you can scope a problem — not that you can list every feature Facebook has ever built.


🏗️ Step 2: Architecture / High-Level Design

Now that we know what we're building, let's design the architecture. Remember — this is a frontend system design. We're not designing the backend fan-out service or the ranking algorithm. We're designing the client-side architecture.

Component Architecture


┌─────────────────────────────────────────────────────┐
│                    App Shell                         │
│  ┌──────────┐  ┌──────────────────────┐  ┌────────┐│
│  │  Header   │  │    Main Content      │  │Sidebar ││
│  │  (Nav)    │  │  ┌────────────────┐  │  │(Chat,  ││
│  │           │  │  │  Stories Bar    │  │  │Contacts││
│  └──────────┘  │  └────────────────┘  │  │Trends) ││
│                │  ┌────────────────┐  │  │        ││
│                │  │ Post Composer   │  │  │        ││
│                │  └────────────────┘  │  │        ││
│                │  ┌────────────────┐  │  │        ││
│                │  │   Feed List     │  │  │        ││
│                │  │  ┌────────────┐│  │  │        ││
│                │  │  │ Feed Post  ││  │  │        ││
│                │  │  │  ┌───────┐ ││  │  │        ││
│                │  │  │  │Header ││  │  │        ││
│                │  │  │  │Content││  │  │        ││
│                │  │  │  │Actions││  │  │        ││
│                │  │  │  │Comment││  │  │        ││
│                │  │  │  └───────┘ ││  │  │        ││
│                │  │  └────────────┘│  │  │        ││
│                │  │  ... more posts │  │  └────────┘│
│                │  └────────────────┘  │            │
│                └──────────────────────┘            │
└─────────────────────────────────────────────────────┘

Component Responsibilities

Component

Responsibility

Key Decisions

App Shell

Layout orchestration, routing, auth context

Renders once, never re-renders on feed updates

Header/Nav

Navigation, search, notifications badge

Fixed position, lazy-load notification dropdown

Stories Bar

Horizontal scrollable story thumbnails

Virtualized horizontal list, preload adjacent stories

Post Composer

Create new posts with text, media, audience

Expands on focus, media upload with preview

Feed List

Infinite scroll container for feed posts

Virtualized list, intersection observer for loading

Feed Post

Individual post rendering

Polymorphic — renders differently for text/image/video/link

Post Actions

Like, comment, share buttons

Optimistic updates, reaction picker on long-press/hover

Comments Section

Threaded comments under a post

Lazy-loaded, collapsed by default, paginated

Sidebar

Contacts list, trending topics, sponsored content

Sticky position, independent data fetching

Rendering Strategy: SSR + CSR Hybrid

This is a critical architectural decision. Let's explore three approaches:

Approach 1: Pure Client-Side Rendering (CSR)


// Traditional SPA approach
function App() {
  return (
    <BrowserRouter>
      <AuthProvider>
        <Route path="/feed" element={<FeedPage />} />
      </AuthProvider>
    </BrowserRouter>
  );
}

Pros

Cons

Simple deployment (static hosting)

Slow initial load (blank screen → content)

Rich interactivity

Poor SEO (not critical for auth'd feed)

Easy to reason about state

Large JS bundle blocks rendering

Approach 2: Server-Side Rendering (SSR)


// Next.js style SSR
export async function getServerSideProps(context) {
  const { req } = context;
  const token = req.cookies.session;
  const feedData = await fetchFeed(token, { limit: 10 });

  return {
    props: { initialFeed: feedData }
  };
}

function FeedPage({ initialFeed }) {
  const [posts, setPosts] = useState(initialFeed);
  // Hydrate and take over with CSR for subsequent interactions
}

Pros

Cons

Fast First Contentful Paint (FCP)

Server cost per request

Content visible before JS loads

Hydration complexity & mismatches

Better perceived performance

TTFB depends on server/data latency

Approach 3: Streaming SSR + Selective Hydration (Facebook's Actual Approach)


// React 18 streaming with Suspense boundaries
function FeedPage() {
  return (
    <Layout>
      <StoriesBar />           {/* Hydrates immediately */}
      <PostComposer />         {/* Hydrates immediately */}
      <Suspense fallback={<FeedSkeleton />}>
        <FeedList />            {/* Streams in, hydrates when ready */}
      </Suspense>
      <Suspense fallback={<SidebarSkeleton />}>
        <Sidebar />             {/* Lowest priority hydration */}
      </Suspense>
    </Layout>
  );
}

Pros

Cons

Best of both worlds — fast FCP + rich interactivity

Complex infrastructure (streaming server)

Progressive hydration — critical UI interactive first

Requires React 18+ and careful Suspense boundaries

Non-blocking — sidebar can load independently

Debugging hydration issues is painful

💡 Recommendation: For a Facebook-scale app, Approach 3 is the winner. Facebook actually pioneered this approach. The initial HTML streams in with the critical above-the-fold content, and React progressively hydrates components as their JS chunks arrive. This gives users a perceived load time of <1 second even on slow connections.


📦 Step 3: Data Model / Core Entities

The data model defines what flows through your system. Getting this right is crucial — a bad data model means constant refactoring later.

Core Entities


// ===== Core Data Types =====

interface User {
  id: string;
  name: string;
  avatarUrl: string;
  profileUrl: string;
}

interface FeedPost {
  id: string;
  author: User;
  content: PostContent;
  createdAt: string;         // ISO 8601 UTC timestamp
  updatedAt: string;
  audience: 'public' | 'friends' | 'only_me';
  reactions: ReactionSummary;
  commentsCount: number;
  sharesCount: number;
  hasUserReacted: ReactionType | null;
  hasUserBookmarked: boolean;
}

// Polymorphic content — this is KEY
type PostContent =
  | { type: 'text'; body: string }
  | { type: 'image'; body: string; images: MediaItem[] }
  | { type: 'video'; body: string; video: VideoItem }
  | { type: 'link'; body: string; linkPreview: LinkPreview }
  | { type: 'shared_post'; body: string; originalPost: FeedPost };

interface MediaItem {
  id: string;
  url: string;
  thumbnailUrl: string;
  width: number;
  height: number;
  altText: string;
}

interface VideoItem {
  id: string;
  url: string;
  thumbnailUrl: string;
  duration: number;          // seconds
  width: number;
  height: number;
  hlsUrl?: string;           // For adaptive bitrate streaming
}

interface LinkPreview {
  url: string;
  title: string;
  description: string;
  imageUrl: string;
  siteName: string;
  favicon: string;
}

type ReactionType = 'like' | 'love' | 'haha' | 'wow' | 'sad' | 'angry';

interface ReactionSummary {
  total: number;
  topReactions: ReactionType[];  // Top 3 reactions to show icons
  byType: Partial<Record<ReactionType, number>>;
}

interface Comment {
  id: string;
  author: User;
  content: string;
  createdAt: string;
  reactions: ReactionSummary;
  replies: Comment[];        // Nested threading
  repliesCount: number;
  hasUserReacted: ReactionType | null;
}

interface Story {
  id: string;
  author: User;
  mediaUrl: string;
  mediaType: 'image' | 'video';
  createdAt: string;
  expiresAt: string;         // 24 hours from creation
  isViewed: boolean;
}

Why This Data Model Works

1. Polymorphic PostContent: Instead of cramming everything into one flat object, we use discriminated unions. This means the rendering layer can switch on content.type and TypeScript will narrow the type automatically:


function PostBody({ content }: { content: PostContent }) {
  switch (content.type) {
    case 'text':
      return <TextPost body={content.body} />;
    case 'image':
      return <ImagePost body={content.body} images={content.images} />;
    case 'video':
      return <VideoPost body={content.body} video={content.video} />;
    case 'link':
      return <LinkPost body={content.body} preview={content.linkPreview} />;
    case 'shared_post':
      return <SharedPost body={content.body} original={content.originalPost} />;
  }
}

2. Denormalized User in Post: We embed the User object directly in the post instead of just a userId. Why? Because every single post needs to render the author's name and avatar. A normalized approach (storing only userId and looking up separately) would require an additional lookup for every post — that's death by a thousand queries.

3. UTC Timestamps: Always send raw UTC timestamps from the server, never pre-formatted strings. The client can format them using Intl.RelativeTimeFormat:


function RelativeTime({ timestamp }: { timestamp: string }) {
  const [display, setDisplay] = useState('');

  useEffect(() => {
    const update = () => {
      const seconds = Math.floor(
        (Date.now() - new Date(timestamp).getTime()) / 1000
      );

      const rtf = new Intl.RelativeTimeFormat('en', { numeric: 'auto' });

      if (seconds < 60) setDisplay(rtf.format(-seconds, 'second'));
      else if (seconds < 3600) setDisplay(rtf.format(-Math.floor(seconds / 60), 'minute'));
      else if (seconds < 86400) setDisplay(rtf.format(-Math.floor(seconds / 3600), 'hour'));
      else setDisplay(rtf.format(-Math.floor(seconds / 86400), 'day'));
    };

    update();
    const interval = setInterval(update, 60000); // Update every minute
    return () => clearInterval(interval);
  }, [timestamp]);

  return <time dateTime={timestamp}>{display}</time>;
}

Client-Side State Structure


// Using Zustand for state management
interface FeedStore {
  // Feed data
  posts: Map<string, FeedPost>;     // Normalized by ID for O(1) lookups
  feedOrder: string[];               // Array of post IDs in display order
  cursor: string | null;             // For pagination
  hasMore: boolean;

  // UI state
  isLoadingMore: boolean;
  isRefreshing: boolean;
  newPostsCount: number;             // "3 new posts" banner
  pendingPosts: FeedPost[];          // Optimistic posts awaiting confirmation

  // Actions
  fetchFeed: (cursor?: string) => Promise<void>;
  createPost: (content: PostContent) => Promise<void>;
  reactToPost: (postId: string, reaction: ReactionType) => void;
  deletePost: (postId: string) => void;
  showNewPosts: () => void;
}

Why Map + Ordered Array? This is a common pattern in production apps. The Map<string, FeedPost> gives us O(1) lookups when we need to update a specific post (e.g., after a reaction). The feedOrder: string[] maintains display order. This separation is crucial because:

  • Updating a reaction only modifies the Map entry — the order array is untouched, so the feed list doesn't re-render

  • Adding new posts to the top only modifies the order array

  • Deduplication is trivial — just check if the ID exists in the Map


🔌 Step 4: Interface Definition (API Design)

This is where most candidates go shallow. Don't just say "we'll have a REST API." Define the actual contracts.

API Overview

Source

Destination

Protocol

Purpose

Server

Client

REST (HTTP)

Fetch feed, create posts, CRUD operations

Server

Client

WebSocket / SSE

Real-time new posts, live reactions

Client

CDN

HTTP

Static assets, media files

REST API Contracts

1. Fetch Feed (Cursor-based Pagination)


// GET /api/v1/feed?cursor={lastPostTimestamp}&limit=10

// Request
interface FetchFeedRequest {
  cursor?: string;   // ISO timestamp of last post seen
  limit?: number;    // Default 10, max 25
}

// Response
interface FetchFeedResponse {
  posts: FeedPost[];
  pagination: {
    nextCursor: string | null;  // null means no more posts
    hasMore: boolean;
  };
  metadata: {
    totalUnread: number;        // For "new posts" banner
    serverTime: string;         // For clock skew correction
  };
}

Why cursor-based over offset-based pagination?

Feature

Offset-based (?page=2&limit=10)

Cursor-based (?cursor=timestamp)

New posts inserted at top

❌ Causes duplicates on next page

✅ Cursor is stable

Deleted posts

❌ Causes items to shift, skipping content

✅ Cursor remains valid

Performance at scale

❌ OFFSET 10000 scans 10K rows

✅ Index seek, constant time

Simplicity

✅ Easy to implement "page 3 of 10"

❌ No random page access

Best for

Static content, admin panels

Dynamic feeds, chat, infinite scroll

For a news feed with constant insertions, cursor-based is the only sane choice.

2. Create Post


// POST /api/v1/posts

// Request (multipart/form-data for media)
interface CreatePostRequest {
  content: string;
  contentType: 'text' | 'image' | 'video' | 'link';
  audience: 'public' | 'friends' | 'only_me';
  media?: File[];               // Up to 10 images or 1 video
  linkUrl?: string;             // For link-type posts
}

// Response
interface CreatePostResponse {
  post: FeedPost;               // Full post object with generated ID
  uploadUrls?: string[];        // Pre-signed URLs for media upload
}

Media Upload Strategy: Two approaches:

Approach A: Direct Upload


// Client uploads media directly in the POST request
const formData = new FormData();
formData.append('content', 'Check out this photo!');
formData.append('media', file);

const response = await fetch('/api/v1/posts', {
  method: 'POST',
  body: formData,
});

Pros: Simple. Cons: Blocks post creation until upload completes. Large files timeout.

Approach B: Pre-signed URL Upload (Recommended)


// Step 1: Create post, get pre-signed upload URLs
const { post, uploadUrls } = await createPost({ content, contentType: 'image' });

// Step 2: Upload media directly to cloud storage (S3/GCS)
await Promise.all(
  files.map((file, i) =>
    fetch(uploadUrls[i], {
      method: 'PUT',
      body: file,
      headers: { 'Content-Type': file.type },
    })
  )
);

// Step 3: Notify server that upload is complete
await fetch(`/api/v1/posts/${post.id}/media-ready`, { method: 'POST' });

Pros: Non-blocking, handles large files, upload directly to CDN. Cons: More complex, needs cleanup for abandoned uploads.

3. React to Post


// POST /api/v1/posts/:postId/reactions

interface ReactRequest {
  reactionType: ReactionType;   // 'like' | 'love' | 'haha' | etc.
}

// Response: 200 OK with updated reaction counts
interface ReactResponse {
  reactions: ReactionSummary;
}

// DELETE /api/v1/posts/:postId/reactions
// Removes user's reaction — Response: same as above

WebSocket Contract (Real-Time Updates)

This is what separates a good answer from a great one. Let's design the real-time layer.

Connection Lifecycle


// Client-side WebSocket manager
class FeedWebSocketManager {
  private ws: WebSocket | null = null;
  private reconnectAttempts = 0;
  private maxReconnectAttempts = 10;
  private heartbeatInterval: number | null = null;

  connect(token: string) {
    this.ws = new WebSocket(
      `wss://api.facebook.com/ws/feed?token=${token}`
    );

    this.ws.onopen = () => {
      this.reconnectAttempts = 0;
      this.startHeartbeat();
      this.subscribe(['feed_updates', 'reactions', 'typing']);
    };

    this.ws.onmessage = (event) => {
      const message: WSMessage = JSON.parse(event.data);
      this.handleMessage(message);
    };

    this.ws.onclose = (event) => {
      this.stopHeartbeat();
      if (!event.wasClean) {
        this.reconnectWithBackoff();
      }
    };
  }

  private startHeartbeat() {
    // Send ping every 30 seconds to keep connection alive
    this.heartbeatInterval = window.setInterval(() => {
      if (this.ws?.readyState === WebSocket.OPEN) {
        this.ws.send(JSON.stringify({ type: 'ping' }));
      }
    }, 30000);
  }

  private reconnectWithBackoff() {
    if (this.reconnectAttempts >= this.maxReconnectAttempts) {
      console.error('Max reconnection attempts reached. Falling back to polling.');
      this.fallbackToPolling();
      return;
    }

    // Exponential backoff: 1s, 2s, 4s, 8s... capped at 30s
    const delay = Math.min(
      1000 * Math.pow(2, this.reconnectAttempts),
      30000
    );

    // Add jitter to prevent thundering herd
    const jitter = delay * 0.3 * Math.random();

    setTimeout(() => {
      this.reconnectAttempts++;
      this.connect(this.currentToken);
    }, delay + jitter);
  }

  private fallbackToPolling() {
    // If WebSocket fails persistently, fall back to HTTP polling
    setInterval(async () => {
      const newPosts = await fetchNewPosts(this.lastSeenTimestamp);
      if (newPosts.length > 0) {
        feedStore.addNewPosts(newPosts);
      }
    }, 30000); // Poll every 30 seconds
  }
}

WebSocket Message Types


// All messages follow this envelope
type WSMessage =
  | { type: 'new_post'; payload: FeedPost }
  | { type: 'post_deleted'; payload: { postId: string } }
  | { type: 'reaction_update'; payload: {
      postId: string;
      reactions: ReactionSummary;
    }}
  | { type: 'comment_added'; payload: {
      postId: string;
      comment: Comment;
      newCount: number;
    }}
  | { type: 'typing_indicator'; payload: {
      postId: string;
      user: User;
      isTyping: boolean;
    }}
  | { type: 'pong' }
  | { type: 'error'; payload: { code: string; message: string } };

Real-Time Update Strategy: The "New Posts" Banner

Here's a subtle but important UX decision. When new posts arrive via WebSocket, we have two options:

Option A: Auto-insert at top (Twitter-style)


// Immediately prepend new posts
ws.onNewPost((post) => {
  feedStore.prependPost(post);
});

Problem: If you're reading a post mid-screen, the content jumps down. This is infuriating.

Option B: Buffer + Banner (Facebook-style) ✅ Recommended


// Buffer new posts, show a clickable banner
ws.onNewPost((post) => {
  feedStore.incrementNewPostsCount();
  feedStore.bufferNewPost(post);
});

// In the UI:
function NewPostsBanner() {
  const { newPostsCount, showNewPosts } = useFeedStore();

  if (newPostsCount === 0) return null;

  return (
    <button
      onClick={showNewPosts}
      className="sticky top-16 z-10 w-full bg-primary text-primary-foreground
                 py-2 rounded-lg shadow-md animate-slide-down"
    >
      {newPostsCount} new {newPostsCount === 1 ? 'post' : 'posts'} — Click to see
    </button>
  );
}

// When clicked:
function showNewPosts() {
  const buffered = feedStore.getBufferedPosts();
  feedStore.prependPosts(buffered);
  feedStore.clearBuffer();
  window.scrollTo({ top: 0, behavior: 'smooth' });
}

Why this is better: The user stays in control. They choose when to see new content. No jarring layout shifts.

SSE vs WebSocket: When to Choose What

Feature

WebSocket

Server-Sent Events (SSE)

Direction

Bidirectional

Server → Client only

Protocol

WS/WSS (custom)

HTTP (standard)

Reconnection

Manual implementation

Built-in auto-reconnect

Binary data

✅ Supported

❌ Text only

HTTP/2 multiplexing

❌ Separate connection

✅ Shares connection

Browser support

Excellent

Excellent (no IE)

Best for

Chat, gaming, collaborative editing

News feeds, notifications, live scores

For a news feed specifically, SSE is actually a great choice because updates are server-to-client only. However, if you need to support typing indicators or read receipts in comments, WebSocket is necessary.


⚡ Step 5: Optimizations & Deep Dive

This is where you differentiate yourself. Let's go section by section.

5.1 Feed List Performance — Virtualization

A typical user might scroll through 200+ posts in a session. Without virtualization, that's 200+ complex DOM nodes in memory. The browser chokes.


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

function VirtualizedFeed({ posts }: { posts: FeedPost[] }) {
  const parentRef = useRef<HTMLDivElement>(null);

  const virtualizer = useVirtualizer({
    count: posts.length,
    getScrollElement: () => parentRef.current,
    estimateSize: (index) => {
      // Estimate based on post type
      const post = posts[index];
      switch (post.content.type) {
        case 'text': return 200;
        case 'image': return 500;
        case 'video': return 450;
        case 'link': return 350;
        default: return 300;
      }
    },
    overscan: 3,  // Render 3 extra items above/below viewport
  });

  return (
    <div ref={parentRef} style={{ height: '100vh', overflow: 'auto' }}>
      <div style={{ height: virtualizer.getTotalSize() }}>
        {virtualizer.getVirtualItems().map((virtualRow) => (
          <div
            key={virtualRow.key}
            style={{
              position: 'absolute',
              top: virtualRow.start,
              width: '100%',
            }}
          >
            <FeedPost post={posts[virtualRow.index]} />
          </div>
        ))}
      </div>
    </div>
  );
}

Approach comparison for long lists:

Approach

DOM Nodes

Memory

Scroll Perf

Implementation

No virtualization

All rendered

High

Degrades over time

Simple

Windowed virtualization

~15-20

Low

Consistent 60fps

Moderate

Hybrid (keep first N, virtualize rest)

~50

Medium

Good

Complex

5.2 Infinite Scroll with Intersection Observer


function useInfiniteScroll(
  loadMore: () => Promise<void>,
  hasMore: boolean
) {
  const sentinelRef = useRef<HTMLDivElement>(null);
  const isLoadingRef = useRef(false);

  useEffect(() => {
    const sentinel = sentinelRef.current;
    if (!sentinel || !hasMore) return;

    const observer = new IntersectionObserver(
      async (entries) => {
        if (entries[0].isIntersecting && !isLoadingRef.current) {
          isLoadingRef.current = true;
          await loadMore();
          isLoadingRef.current = false;
        }
      },
      {
        root: null,
        rootMargin: '400px',  // Trigger 400px BEFORE user reaches bottom
        threshold: 0,
      }
    );

    observer.observe(sentinel);
    return () => observer.disconnect();
  }, [loadMore, hasMore]);

  return sentinelRef;
}

// Usage:
function FeedList() {
  const { posts, fetchMore, hasMore, isLoadingMore } = useFeedStore();
  const sentinelRef = useInfiniteScroll(fetchMore, hasMore);

  return (
    <div role="feed" aria-busy={isLoadingMore}>
      {posts.map((post) => (
        <article key={post.id} aria-posinset={/* position */} aria-setsize={-1}>
          <FeedPost post={post} />
        </article>
      ))}

      {/* Invisible sentinel element */}
      <div ref={sentinelRef} aria-hidden="true" style={{ height: 1 }} />

      {isLoadingMore && <FeedSkeleton count={3} />}
    </div>
  );
}

Why rootMargin: '400px'? This triggers the load 400px before the user reaches the bottom. It creates the illusion of infinite content — by the time they scroll to where the sentinel was, new content is already rendered.

5.3 Optimistic Updates for Reactions

Nobody wants to wait 200ms to see their like register. Optimistic updates make the UI feel instant:


async function reactToPost(postId: string, reaction: ReactionType) {
  const previousPost = feedStore.getPost(postId);

  // Step 1: Optimistically update UI immediately
  feedStore.updatePost(postId, {
    hasUserReacted: reaction,
    reactions: {
      ...previousPost.reactions,
      total: previousPost.reactions.total + 1,
      byType: {
        ...previousPost.reactions.byType,
        [reaction]: (previousPost.reactions.byType[reaction] || 0) + 1,
      },
    },
  });

  try {
    // Step 2: Send to server
    const response = await api.post(`/posts/${postId}/reactions`, {
      reactionType: reaction,
    });

    // Step 3: Reconcile with server response (source of truth)
    feedStore.updatePost(postId, {
      reactions: response.reactions,
    });
  } catch (error) {
    // Step 4: Rollback on failure
    feedStore.updatePost(postId, {
      hasUserReacted: previousPost.hasUserReacted,
      reactions: previousPost.reactions,
    });

    toast.error('Failed to react. Please try again.');
  }
}

5.4 Image Loading Strategy


function PostImage({ image }: { image: MediaItem }) {
  const [loaded, setLoaded] = useState(false);

  return (
    <div
      className="relative bg-muted overflow-hidden"
      style={{
        // Prevent layout shift with aspect ratio box
        aspectRatio: `${image.width} / ${image.height}`,
      }}
    >
      {/* Low-quality placeholder (blurred thumbnail) */}
      {!loaded && (
        <img
          src={image.thumbnailUrl}
          alt=""
          className="absolute inset-0 w-full h-full object-cover blur-lg scale-105"
          aria-hidden="true"
        />
      )}

      {/* Full-quality image */}
      <img
        src={image.url}
        alt={image.altText}
        loading="lazy"
        decoding="async"
        onLoad={() => setLoaded(true)}
        className={`w-full h-full object-cover transition-opacity duration-300
          ${loaded ? 'opacity-100' : 'opacity-0'}`}
        sizes="(max-width: 680px) 100vw, 680px"
        srcSet={`
          ${image.thumbnailUrl} 320w,
          ${image.url}?w=680 680w,
          ${image.url}?w=1360 1360w
        `}
      />
    </div>
  );
}

Why this matters at scale:

  • Aspect ratio box prevents Cumulative Layout Shift (CLS) — the image space is reserved before the image loads

  • Blurred thumbnail placeholder (LQIP) gives immediate visual feedback

  • loading="lazy" defers off-screen images

  • srcSet serves appropriately sized images — no loading a 4K image for a 680px container

5.5 Accessibility Deep Dive


// Feed container with proper ARIA semantics
<div
  role="feed"
  aria-label="News Feed"
  aria-busy={isLoading}
>
  {posts.map((post, index) => (
    <article
      key={post.id}
      aria-posinset={index + 1}
      aria-setsize={hasMore ? -1 : posts.length}
      aria-labelledby={`post-author-${post.id}`}
      tabIndex={0}
    >
      <header>
        <span id={`post-author-${post.id}`}>
          Post by {post.author.name}
        </span>
        <RelativeTime timestamp={post.createdAt} />
      </header>

      <PostBody content={post.content} />

      <footer>
        <button
          aria-pressed={!!post.hasUserReacted}
          aria-label={
            post.hasUserReacted
              ? `Remove ${post.hasUserReacted} reaction`
              : 'Like this post'
          }
        >
          Like ({post.reactions.total})
        </button>
      </footer>
    </article>
  ))}
</div>

Key accessibility patterns:

  • role="feed" — tells screen readers this is a dynamic feed

  • aria-posinset / aria-setsize — position context within the feed (-1 for unknown total)

  • aria-busy — announces loading state

  • aria-pressed on reaction buttons — toggle button pattern

  • Keyboard navigation: Tab between posts, Enter to interact

5.6 Performance Budget & Monitoring

Metric

Target

How to Measure

First Contentful Paint (FCP)

< 1.5s

Web Vitals API

Largest Contentful Paint (LCP)

< 2.5s

Web Vitals API

Cumulative Layout Shift (CLS)

< 0.1

Web Vitals API

Interaction to Next Paint (INP)

< 200ms

Web Vitals API

JS Bundle Size (initial)

< 200KB gzipped

Webpack Bundle Analyzer

Feed scroll FPS

Consistent 60fps

Chrome DevTools Performance panel

Time to Interactive (TTI)

< 3s on 3G

Lighthouse

5.7 How Things Break at Scale

Let's talk about real failure modes — the stuff that crashes at 1M+ concurrent users:

Problem 1: WebSocket Connection Storms

When a server goes down, all connected clients try to reconnect simultaneously. This is called a thundering herd.

Solution: Exponential backoff with jitter (shown in the WebSocket code above). Each client waits a random time before reconnecting, spreading the load.

Problem 2: Memory Leaks from Infinite Scroll

User scrolls through 500 posts → 500 post components in memory → browser tab crashes.

Solution: Virtualization (shown above) + aggressive cleanup of detached media elements:


// Cleanup videos when scrolled off-screen
useEffect(() => {
  return () => {
    // Pause and release video resources when post unmounts
    videoRef.current?.pause();
    videoRef.current?.removeAttribute('src');
    videoRef.current?.load(); // Forces resource release
  };
}, []);

Problem 3: Stale Data After Tab Switch

User opens Facebook, switches to another tab for 2 hours, comes back. The feed is stale, the WebSocket is dead.


// Re-sync when tab becomes visible
document.addEventListener('visibilitychange', () => {
  if (document.visibilityState === 'visible') {
    // Check WebSocket health
    if (ws.readyState !== WebSocket.OPEN) {
      ws.reconnect();
    }

    // Fetch any posts we missed
    feedStore.fetchNewPostsSince(feedStore.lastSeenTimestamp);
  }
});

Problem 4: Race Conditions in Optimistic Updates

User likes a post, then quickly unlikes it. The first API call hasn't returned yet. The second call fires. Now we have conflicting state.


// Solution: AbortController + request deduplication
const pendingReactions = new Map<string, AbortController>();

async function reactToPost(postId: string, reaction: ReactionType | null) {
  // Cancel any pending reaction for this post
  const existing = pendingReactions.get(postId);
  if (existing) {
    existing.abort();
  }

  const controller = new AbortController();
  pendingReactions.set(postId, controller);

  try {
    await api.post(`/posts/${postId}/reactions`, {
      reactionType: reaction,
    }, { signal: controller.signal });
  } catch (error) {
    if (error.name !== 'AbortError') {
      // Real error — rollback
      rollbackReaction(postId);
    }
  } finally {
    pendingReactions.delete(postId);
  }
}

Problem 5: Bundle Size Explosion

As features grow (reactions picker, media player, comment composer, link previews), the main bundle balloons.


// Solution: Code-split by post type and interaction
const VideoPlayer = lazy(() => import('./VideoPlayer'));
const ReactionPicker = lazy(() => import('./ReactionPicker'));
const CommentSection = lazy(() => import('./CommentSection'));
const LinkPreview = lazy(() => import('./LinkPreview'));

function FeedPost({ post }: { post: FeedPost }) {
  return (
    <article>
      <PostHeader author={post.author} time={post.createdAt} />

      {/* Only load the component needed for this post type */}
      <Suspense fallback={<ContentSkeleton type={post.content.type} />}>
        {post.content.type === 'video' && (
          <VideoPlayer video={post.content.video} />
        )}
        {post.content.type === 'link' && (
          <LinkPreview link={post.content.linkPreview} />
        )}
      </Suspense>

      <PostActions post={post} />

      {/* Comments only load when user clicks "View Comments" */}
      <Suspense fallback={<CommentSkeleton />}>
        {showComments && <CommentSection postId={post.id} />}
      </Suspense>
    </article>
  );
}

🔑 Summary: What a Great Answer Looks Like

Section

Key Points to Hit

Time

Requirements

Scope to News Feed. Identify: infinite scroll, post types, reactions, real-time updates.

~3 min

Architecture

Component tree with clear responsibilities. Rendering strategy (SSR + streaming). State management approach.

~7 min

Data Model

Polymorphic PostContent. Denormalized User. Normalized store (Map + order array).

~5 min

API Design

Cursor-based pagination. WebSocket contract with reconnection. Optimistic updates.

~7 min

Optimizations

Virtualization, IntersectionObserver, LQIP images, code splitting, accessibility.

~8 min

Final thoughts from Rahul: The difference between a "pass" and a "strong hire" isn't knowing every optimization. It's showing that you think in trade-offs. Every decision has a cost. Cursor pagination is better for dynamic data but loses random page access. Virtualization saves memory but adds complexity. WebSocket gives real-time but needs reconnection logic. The best candidates don't just pick the "right" answer — they explain why it's right for this specific use case.

Next up: Design Instagram — where we'll tackle image-heavy feeds, stories, explore grids, and the unique challenges of media-first applications.

Related Articles

MediumFrontend Engineering

System Design #12: Design a Multi-Step Form Wizard

7 min read
MediumFrontend Engineering

Mastering Senior-Level JavaScript Interview Concepts

2 min read
MediumFrontend Engineering

System Design #9: Design a Collaborative Text Editor

9 min read

Comments (2)

Sign in to leave a comment.

V
vinitrautFeb 17, 2026

This was a good read! Thanks Rahul.

N
namananandFeb 10, 2026

It's really a good article

Table of Contents

  • 📋 Step 1: Requirements Exploration
  • Clarifying Questions I'd Ask
  • What are Functional vs. Non-Functional Requirements?
  • Functional Requirements for Facebook News Feed
  • Functional Requirements (What the system does)
  • Non-Functional Requirements (How well it does it)
  • 🏗️ Step 2: Architecture / High-Level Design
  • Component Architecture
  • Component Responsibilities
  • Rendering Strategy: SSR + CSR Hybrid
  • 📦 Step 3: Data Model / Core Entities
  • Core Entities
  • Why This Data Model Works
  • Client-Side State Structure
  • 🔌 Step 4: Interface Definition (API Design)
  • API Overview
  • REST API Contracts
  • WebSocket Contract (Real-Time Updates)
  • ⚡ Step 5: Optimizations &amp; Deep Dive
  • 5.1 Feed List Performance — Virtualization
  • 5.2 Infinite Scroll with Intersection Observer
  • 5.3 Optimistic Updates for Reactions
  • 5.4 Image Loading Strategy
  • 5.5 Accessibility Deep Dive
  • 5.6 Performance Budget &amp; Monitoring
  • 5.7 How Things Break at Scale
  • 🔑 Summary: What a Great Answer Looks Like

Series

View all Frontend Engineering articles →

Practice

  • JavaScript
  • DSA
  • Machine Coding
  • System Design

Resources

  • Learning Tracks
  • Articles
  • Roadmaps
  • Compare Concepts
  • Glossary
  • Developer Tools
  • All Questions

Company

  • About
  • Pricing

Legal

  • Privacy Policy
  • Terms of Service
DevPrep

© 2026 DevPrep. All rights reserved.