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)
Browse News Feed — Users see a scrollable list of posts from friends, pages, and groups
Infinite Scroll — More posts load automatically as user scrolls down
Post Interactions — Like (with reaction picker), comment, share
Create Posts — Text, images, videos, with audience selector
Real-time Updates — New posts appear at the top without page refresh
Stories Bar — Horizontal scrollable stories at the top
Non-Functional Requirements (How well it does it)
Performance — Feed loads in under 2 seconds. Scrolling maintains 60fps.
Scalability — Handle millions of concurrent users
Accessibility — Screen reader compatible, keyboard navigable
SEO — Not critical for authenticated feeds, but good for public pages
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 ( | Cursor-based ( |
|---|---|---|
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 | ❌ | ✅ 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 aboveWebSocket 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 imagessrcSetserves 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 feedaria-posinset/aria-setsize— position context within the feed (-1 for unknown total)aria-busy— announces loading statearia-pressedon reaction buttons — toggle button patternKeyboard 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.