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 Instagram — Frontend Architecture That Handles 2 Billion Users
XLinkedInReddit
Frontend Engineering

System Design: Design Instagram — Frontend Architecture That Handles 2 Billion Users

D
DevPrep Team
February 10, 2026·18 min read·58
Table of Contents
  • 📋 Step 1: Requirements Exploration
  • Clarifying Questions I'd Ask
  • Functional Requirements (Core Scope)
  • Non-Functional Requirements
  • 🏗️ Step 2: Architecture / High-Level Design
  • Component Architecture
  • Why This Architecture Wins
  • The Media Rendering Pipeline
  • 📊 Step 3: Data Model
  • Client-Side State Architecture
  • Why Normalization Matters Here
  • 🔌 Step 4: Interface Definition (API Design)
  • Feed API
  • Interaction APIs (Optimistic Pattern)
  • Real-time Updates via WebSocket
  • ⚡ Step 5: Optimizations
  • 1. Responsive Image Serving with Art Direction
  • 2. Video Autoplay Strategy
  • 3. Carousel Performance with Lazy Slide Loading
  • 4. Skeleton Loading That Prevents Layout Shift
  • 5. Offline Support with Service Worker
  • 6. Accessibility for Image-Heavy Content
  • 🎯 Deep Dive: The Story Progress Bar
  • 📊 Performance Budget
  • 🧠 Summary: What Makes This a 5/5 Answer

A production-grade frontend system design walkthrough — the answer that scores 5/5 across every rubric at Meta, Google, and top startups.

Instagram isn't just a photo app anymore. It's a feed, stories, reels, DMs, explore, shopping, and live — all stitched together into a single-page app that serves 2 billion monthly active users. When an interviewer says "Design Instagram," they're testing whether you can architect something that feels buttery smooth while juggling media-heavy content, real-time interactions, and aggressive performance budgets.

I've seen engineers at Google and Meta fail this question — not because they lacked skill, but because they treated it like a backend question. Instagram's frontend is where the hardest problems live: image rendering pipelines, infinite scroll with mixed media, skeleton states that don't cause layout shifts, and an interaction model that handles double-tap likes with zero perceived latency.

Let's build this the right way using the RADIO framework.


📋 Step 1: Requirements Exploration

The interviewer said "Design Instagram." That's 15+ features. You need to scope this down in 3 minutes or you'll drown.

Clarifying Questions I'd Ask

Question

Why It Matters

Assumed Answer

Which part of Instagram are we designing?

Instagram has feed, stories, reels, explore, DMs, profiles. We need focus.

The Feed + Stories experience — the core loop

What media types should the feed support?

Video changes rendering pipeline entirely

Images, carousels (multi-image), short videos

What interactions exist on a post?

Double-tap like, save, share, comments — each has latency implications

Like (double-tap + button), comment, share, save

Should stories be interactive?

Polls, questions, stickers add complexity

Yes — polls, emoji reactions, reply

Do we need real-time updates?

Live like counts, new story indicators

Yes — live like counts, story availability

What's the target device?

Instagram is mobile-first — this changes everything

Mobile-first, responsive to desktop

How important is offline support?

Users in emerging markets have flaky connections

Show cached feed offline, queue interactions

Do we need accessibility support?

Screen readers with image-heavy content is hard

Yes — alt text, keyboard navigation, ARIA

Functional Requirements (Core Scope)

  • Feed: Infinite-scrolling feed of posts (images, carousels, videos) with like, comment, share, save
  • Stories: Horizontal story tray with auto-advancing, tap-to-pause, swipe-to-skip
  • Post Creation: Upload images/videos with filters, captions, and location tagging
  • Interactions: Double-tap like with heart animation, optimistic comment posting
  • Real-time: Live like counts, story ring updates, new post indicators

Non-Functional Requirements

  • Performance: First Contentful Paint < 1.5s, Time to Interactive < 3s on 3G
  • Bandwidth efficiency: Serve appropriate image sizes (srcset/sizes), lazy load below-fold
  • Smooth scrolling: 60fps scroll with mixed media content — no jank
  • Offline-first: Cached feed viewable offline, interactions queued and synced
  • Accessibility: WCAG 2.1 AA — screen reader support for image-heavy content
  • Internationalization: RTL support, localized timestamps, translated UI

🔥 Real-world war story: In 2019, Instagram's web team discovered that their feed was causing 300ms layout shifts on scroll because image aspect ratios weren't reserved in the DOM. Users on Pixel 3a devices reported "jumpy feeds." The fix? A padding-bottom hack based on aspect ratios sent from the API. This single change improved their CLS score by 40%. Always ask about layout stability in your requirements.


🏗️ Step 2: Architecture / High-Level Design

Instagram's frontend is a masterclass in progressive loading and media pipeline optimization. Here's how I'd architect it:

Component Architecture

App
├── Shell (Navigation + Bottom Tab Bar)
│   ├── HeaderBar (Logo, Notifications, DMs badge)
│   └── BottomNav (Home, Search, Create, Reels, Profile)
├── FeedPage
│   ├── StoryTray
│   │   ├── StoryAvatar (ring animation, seen/unseen state)
│   │   └── StoryViewer (fullscreen overlay)
│   │       ├── StorySlide (image/video + interactive stickers)
│   │       ├── StoryProgress (segmented progress bars)
│   │       └── StoryReplyInput
│   ├── FeedList (virtualized infinite scroll)
│   │   └── FeedPost
│   │       ├── PostHeader (avatar, username, follow button, menu)
│   │       ├── PostMedia
│   │       │   ├── SingleImage (progressive JPEG, blur-up)
│   │       │   ├── Carousel (swipeable, dot indicators)
│   │       │   └── VideoPlayer (autoplay on viewport, muted)
│   │       ├── PostActions (like, comment, share, save)
│   │       ├── LikeCount (real-time, abbreviated)
│   │       ├── PostCaption (expandable, hashtag links)
│   │       └── CommentPreview (top 2 comments, "View all" link)
│   └── NewPostsBanner ("New posts available" sticky banner)
├── PostComposer
│   ├── MediaPicker (grid of camera roll)
│   ├── FilterEditor (CSS filter pipeline)
│   ├── CaptionEditor (mentions, hashtags autocomplete)
│   └── LocationPicker
└── Shared
    ├── DoubleTapLike (heart animation overlay)
    ├── UserAvatar (with story ring indicator)
    ├── SkeletonPost (placeholder matching exact post dimensions)
    └── OfflineIndicator

Why This Architecture Wins

1. Feed virtualization is non-negotiable. Instagram feeds contain mixed-height items (single images, carousels, videos). A naive render of 100 posts means 100+ DOM nodes with images, videos, and interaction buttons. On a Pixel 4a, this causes 2+ second scroll jank spikes.

The solution: windowed rendering using a library like react-window or a custom virtualization layer. Only render posts within a 2-viewport buffer. But here's the catch most candidates miss — variable height virtualization is fundamentally harder than fixed-height. You need to measure each post's height after render and cache it.

// Variable-height virtualization with cached measurements
interface PostMeasurement {
  id: string;
  height: number;
  estimatedHeight: number;
  measured: boolean;
}

class FeedVirtualizer {
  private measurements = new Map<string, PostMeasurement>();
  private resizeObserver: ResizeObserver;
  
  constructor() {
    this.resizeObserver = new ResizeObserver((entries) => {
      for (const entry of entries) {
        const postId = entry.target.getAttribute('data-post-id');
        if (postId) {
          this.measurements.set(postId, {
            id: postId,
            height: entry.contentRect.height,
            estimatedHeight: entry.contentRect.height,
            measured: true
          });
        }
      }
      // Batch recalculate offsets
      requestAnimationFrame(() => this.recalculateOffsets());
    });
  }

  getEstimatedHeight(post: Post): number {
    const cached = this.measurements.get(post.id);
    if (cached?.measured) return cached.height;
    
    // Smart estimation based on content type
    const BASE_HEIGHT = 56 + 44 + 60; // header + actions + caption
    switch (post.mediaType) {
      case 'image':
        return BASE_HEIGHT + (post.aspectRatio ? 
          window.innerWidth / post.aspectRatio : 400);
      case 'carousel':
        return BASE_HEIGHT + (post.aspectRatio ? 
          window.innerWidth / post.aspectRatio : 400) + 24; // dots
      case 'video':
        return BASE_HEIGHT + (post.aspectRatio ? 
          window.innerWidth / post.aspectRatio : 500);
      default:
        return BASE_HEIGHT + 400;
    }
  }
}

🔥 Real-world war story: The Instagram web team at Meta tried using react-virtualized in 2020 and hit a wall: their feed had variable aspect ratios and the library's CellMeasurer was re-measuring on every scroll direction change, causing 15ms frame drops. They built a custom virtualizer that pre-computes estimated heights from API metadata (the server sends aspect ratios) and only corrects after first paint. This is now standard practice.

2. Stories architecture as a state machine. Stories seem simple — tap forward, tap back, auto-advance. But edge cases are brutal: what happens when a video story is loading? When the user taps during a transition? When they long-press to pause?

// Story viewer as a finite state machine
type StoryState = 
  | { type: 'IDLE' }
  | { type: 'VIEWING'; userId: string; slideIndex: number }
  | { type: 'PAUSED'; userId: string; slideIndex: number; pauseReason: 'long_press' | 'video_loading' | 'reply_open' }
  | { type: 'TRANSITIONING'; from: string; to: string }
  | { type: 'REPLY_OPEN'; userId: string; slideIndex: number };

type StoryAction = 
  | { type: 'TAP_RIGHT' }
  | { type: 'TAP_LEFT' }
  | { type: 'LONG_PRESS_START' }
  | { type: 'LONG_PRESS_END' }
  | { type: 'SWIPE_NEXT_USER' }
  | { type: 'SWIPE_PREV_USER' }
  | { type: 'VIDEO_BUFFERING' }
  | { type: 'VIDEO_READY' }
  | { type: 'TIMER_COMPLETE' }
  | { type: 'OPEN_REPLY' }
  | { type: 'CLOSE_REPLY' }
  | { type: 'CLOSE' };

function storyReducer(state: StoryState, action: StoryAction): StoryState {
  switch (state.type) {
    case 'VIEWING':
      switch (action.type) {
        case 'TAP_RIGHT':
          // If last slide of this user, go to next user
          if (isLastSlide(state)) {
            return { type: 'TRANSITIONING', from: state.userId, to: getNextUserId(state.userId) };
          }
          return { ...state, slideIndex: state.slideIndex + 1 };
        
        case 'LONG_PRESS_START':
          return { type: 'PAUSED', ...state, pauseReason: 'long_press' };
        
        case 'VIDEO_BUFFERING':
          return { type: 'PAUSED', ...state, pauseReason: 'video_loading' };
        
        case 'TIMER_COMPLETE':
          // Auto-advance to next slide
          return storyReducer(state, { type: 'TAP_RIGHT' });
        
        case 'OPEN_REPLY':
          return { type: 'REPLY_OPEN', userId: state.userId, slideIndex: state.slideIndex };
        
        default:
          return state;
      }
    
    case 'PAUSED':
      switch (action.type) {
        case 'LONG_PRESS_END':
          if (state.pauseReason === 'long_press') {
            return { type: 'VIEWING', userId: state.userId, slideIndex: state.slideIndex };
          }
          return state;
        
        case 'VIDEO_READY':
          if (state.pauseReason === 'video_loading') {
            return { type: 'VIEWING', userId: state.userId, slideIndex: state.slideIndex };
          }
          return state;
        
        default:
          return state;
      }
    
    default:
      return state;
  }
}

🔥 Real-world war story: Snapchat's web viewer had a notorious bug where long-pressing to pause during a transition would cause the progress bar to desync from the actual slide. The root cause? They were using multiple setTimeouts instead of a state machine. Instagram learned from this and uses an xstate-style state machine internally for their story viewer.

The Media Rendering Pipeline

This is where Instagram's frontend gets genuinely hard. You're rendering a feed where every item contains at least one image, many contain carousels, and some contain autoplaying video. All on a mobile device with limited memory.

// Progressive image loading pipeline
class ImagePipeline {
  private loadQueue: PriorityQueue<ImageLoadTask>;
  private maxConcurrent = 4; // Browser connection limit per domain
  private activeLoads = 0;
  
  enqueue(task: ImageLoadTask) {
    // Priority: viewport images > 1-ahead > 2-ahead > below fold
    const priority = this.calculatePriority(task);
    this.loadQueue.enqueue(task, priority);
    this.processQueue();
  }

  private calculatePriority(task: ImageLoadTask): number {
    const viewportDistance = task.estimatedOffsetFromViewport;
    if (viewportDistance < 0) return 0; // Already scrolled past, lowest priority
    if (viewportDistance < window.innerHeight) return 100; // In viewport, highest
    if (viewportDistance < window.innerHeight * 2) return 80; // Next screen
    return Math.max(10, 60 - viewportDistance / 100); // Decreasing priority
  }

  async loadImage(task: ImageLoadTask): Promise<void> {
    // Stage 1: Show BlurHash placeholder (instant)
    task.element.style.backgroundImage = decodeBlurHash(task.blurHash);
    
    // Stage 2: Load thumbnail (low-res, ~2KB)
    const thumbnail = await fetch(task.thumbnailUrl);
    task.element.src = URL.createObjectURL(await thumbnail.blob());
    
    // Stage 3: Load full resolution with srcset
    const fullImage = new Image();
    fullImage.srcset = task.srcSet; // e.g., "640w, 1080w, 1440w"
    fullImage.sizes = task.sizes;   // e.g., "(max-width: 768px) 100vw, 614px"
    
    await new Promise((resolve) => {
      fullImage.onload = resolve;
    });
    
    // Crossfade from thumbnail to full
    task.element.style.transition = 'opacity 200ms';
    task.element.src = fullImage.currentSrc;
  }
}

This three-stage loading (BlurHash → thumbnail → full) is exactly what Instagram uses in production. The BlurHash is a 20-30 byte string that decodes to a blurred preview, eliminating the gray/white flash users see while images load.


📊 Step 3: Data Model

The data model needs to handle three key challenges: normalized entities for deduplication, feed ordering for infinite scroll, and optimistic updates for interactions.

Client-Side State Architecture

// Normalized store using Zustand
interface InstagramStore {
  // === Normalized entities ===
  posts: Record<string, Post>;
  users: Record<string, User>;
  comments: Record<string, Comment>;
  stories: Record<string, Story>;
  
  // === Feed state ===
  feed: {
    postIds: string[];           // Ordered list of post IDs
    cursor: string | null;       // Pagination cursor
    hasMore: boolean;
    isLoading: boolean;
    isRefreshing: boolean;
    newPostsAvailable: boolean;  // "New posts" banner
  };
  
  // === Story tray state ===
  storyTray: {
    userIds: string[];           // Users with active stories
    viewedUserIds: Set<string>; // Users whose stories we've seen
  };
  
  // === Optimistic mutations queue ===
  pendingMutations: PendingMutation[];
  
  // === Offline cache ===
  lastSyncTimestamp: number;
  offlineQueue: QueuedAction[];
}

interface Post {
  id: string;
  authorId: string;              // References users map
  mediaItems: MediaItem[];       // Array for carousels
  caption: string;
  hashtags: string[];
  location: Location | null;
  likeCount: number;
  commentCount: number;
  isLikedByMe: boolean;
  isSavedByMe: boolean;
  topCommentIds: string[];       // First 2 comments
  createdAt: string;
  // Media metadata for layout
  aspectRatio: number;           // Critical for virtualization
  blurHash: string;              // For progressive loading
  dominantColor: string;         // Fallback background
}

interface MediaItem {
  id: string;
  type: 'image' | 'video';
  url: string;
  thumbnailUrl: string;
  srcSet: string;                // Responsive image sources
  width: number;
  height: number;
  blurHash: string;
  // Video-specific
  duration?: number;
  hlsUrl?: string;               // Adaptive streaming
  hasAudio?: boolean;
}

interface Story {
  id: string;
  userId: string;
  slides: StorySlide[];
  expiresAt: string;
  seenSlideIndex: number;         // Track last seen slide
}

interface StorySlide {
  id: string;
  type: 'image' | 'video';
  url: string;
  duration: number;               // Seconds to display (images default 5s)
  stickers: Sticker[];            // Interactive elements
  musicTrack?: MusicTrack;
}

// Optimistic mutation for offline support
interface PendingMutation {
  id: string;
  type: 'like' | 'unlike' | 'comment' | 'save' | 'unsave';
  entityId: string;
  payload: unknown;
  timestamp: number;
  status: 'pending' | 'syncing' | 'failed';
  retryCount: number;
}

Why Normalization Matters Here

Consider this scenario: User A appears in your feed (as a post author), in your stories tray, and in a comment on another post. Without normalization, their data exists in 3 places. When they change their profile picture, you'd need to update all 3 — and you'd inevitably miss one.

🔥 Real-world war story: Twitter (now X) had exactly this bug for years. If you changed your avatar, old tweets in the feed still showed the old image until you refreshed. The fix was migrating to a normalized store — but that refactoring took their frontend team 6 months. Instagram avoided this from day one by normalizing early.


🔌 Step 4: Interface Definition (API Design)

Feed API

// GET /api/v1/feed?cursor={cursor}&limit=10
interface FeedResponse {
  posts: PostDTO[];
  users: UserDTO[];           // Denormalized for fewer round-trips
  nextCursor: string | null;
  hasMore: boolean;
  // Server-driven UI hints
  serverTime: string;         // For relative timestamps
  experimentFlags: Record<string, boolean>; // A/B test flags
}

interface PostDTO {
  id: string;
  author_id: string;
  media: {
    type: 'image' | 'carousel' | 'video';
    items: Array<{
      url: string;
      thumbnail_url: string;
      srcset: string;          // "640w, 1080w, 1440w"
      width: number;
      height: number;
      blur_hash: string;
      // Video fields
      hls_url?: string;
      duration_seconds?: number;
    }>;
  };
  caption: string;
  like_count: number;
  comment_count: number;
  viewer_has_liked: boolean;
  viewer_has_saved: boolean;
  top_comments: CommentDTO[];  // Embedded, not separate request
  created_at: string;
  location?: { name: string; lat: number; lng: number };
}

Interaction APIs (Optimistic Pattern)

// Like/Unlike with optimistic updates
async function toggleLike(postId: string): Promise<void> {
  const post = store.getState().posts[postId];
  const newLiked = !post.isLikedByMe;
  
  // Step 1: Optimistic update (instant)
  store.setState(state => ({
    posts: {
      ...state.posts,
      [postId]: {
        ...post,
        isLikedByMe: newLiked,
        likeCount: post.likeCount + (newLiked ? 1 : -1)
      }
    }
  }));
  
  // Step 2: Trigger haptic feedback + heart animation
  if (newLiked) {
    triggerHaptic('light');
    showHeartAnimation(postId);
  }
  
  // Step 3: Sync to server (with retry)
  const mutation: PendingMutation = {
    id: crypto.randomUUID(),
    type: newLiked ? 'like' : 'unlike',
    entityId: postId,
    payload: {},
    timestamp: Date.now(),
    status: 'pending',
    retryCount: 0
  };
  
  try {
    await api.post(`/posts/${postId}/${newLiked ? 'like' : 'unlike'}`);
    removePendingMutation(mutation.id);
  } catch (error) {
    if (!navigator.onLine) {
      // Queue for later sync
      addToOfflineQueue(mutation);
    } else {
      // Rollback optimistic update
      store.setState(state => ({
        posts: {
          ...state.posts,
          [postId]: {
            ...post, // Restore original state
          }
        }
      }));
      showError('Failed to update. Please try again.');
    }
  }
}

// Double-tap like handler with gesture detection
function useDoubleTapLike(postId: string) {
  const lastTap = useRef<number>(0);
  const tapTimeout = useRef<NodeJS.Timeout>();
  
  const handleTap = useCallback((e: React.TouchEvent) => {
    const now = Date.now();
    const DOUBLE_TAP_THRESHOLD = 300; // ms
    
    if (now - lastTap.current < DOUBLE_TAP_THRESHOLD) {
      // Double tap detected
      clearTimeout(tapTimeout.current);
      const post = store.getState().posts[postId];
      if (!post.isLikedByMe) {
        toggleLike(postId);
      } else {
        // Already liked — still show heart animation
        showHeartAnimation(postId);
      }
    } else {
      // Single tap — wait to confirm it's not a double tap
      tapTimeout.current = setTimeout(() => {
        // Single tap action (if any)
      }, DOUBLE_TAP_THRESHOLD);
    }
    lastTap.current = now;
  }, [postId]);
  
  return handleTap;
}

🔥 Real-world war story: Instagram's "double-tap to like" had a subtle bug on Android WebView: the touchend event would fire twice on some Samsung devices due to a Chrome bug. The result? Every double-tap unlike would immediately re-like. Their fix was debouncing based on the touch event's identifier, not just timestamps. Always test touch interactions on real Android devices.

Real-time Updates via WebSocket

// WebSocket protocol for real-time feed updates
interface WSMessage {
  type: 'like_update' | 'comment_update' | 'story_update' | 'new_post' | 'typing_indicator';
  payload: unknown;
}

class FeedWebSocket {
  private ws: WebSocket;
  private reconnectDelay = 1000;
  private maxReconnectDelay = 30000;
  
  connect() {
    this.ws = new WebSocket('wss://api.instagram.com/ws/feed');
    
    this.ws.onmessage = (event) => {
      const message: WSMessage = JSON.parse(event.data);
      
      switch (message.type) {
        case 'like_update':
          // Only update if we don't have a pending local mutation
          const pending = store.getState().pendingMutations
            .find(m => m.entityId === message.payload.postId && m.type === 'like');
          if (!pending) {
            store.setState(state => ({
              posts: {
                ...state.posts,
                [message.payload.postId]: {
                  ...state.posts[message.payload.postId],
                  likeCount: message.payload.newCount
                }
              }
            }));
          }
          break;
        
        case 'new_post':
          // Don't insert directly — show "New posts" banner
          store.setState(state => ({
            feed: { ...state.feed, newPostsAvailable: true }
          }));
          break;
        
        case 'story_update':
          // Add/remove user from story tray
          updateStoryTray(message.payload);
          break;
      }
    };
    
    this.ws.onclose = () => {
      // Exponential backoff reconnect
      setTimeout(() => this.connect(), this.reconnectDelay);
      this.reconnectDelay = Math.min(
        this.reconnectDelay * 2, 
        this.maxReconnectDelay
      );
    };
  }
}

⚡ Step 5: Optimizations

This is where you separate a "good" answer from a "perfect" answer. These optimizations are what Instagram actually does in production.

1. Responsive Image Serving with Art Direction

<!-- Not just srcset — art direction for different viewports -->
<picture>
  <!-- Desktop: show wider crop -->
  <source 
    media="(min-width: 1024px)" 
    srcset="/img/post-1080.webp 1080w, /img/post-1440.webp 1440w"
    sizes="614px"
    type="image/webp"
  />
  <!-- Mobile: full-width -->
  <source 
    srcset="/img/post-640.webp 640w, /img/post-1080.webp 1080w"
    sizes="100vw"
    type="image/webp"
  />
  <!-- Fallback -->
  <img 
    src="/img/post-1080.jpg" 
    alt={post.accessibilityText}
    loading="lazy"
    decoding="async"
    style={{ aspectRatio: `${post.width}/${post.height}` }}
  />
</picture>

Instagram serves images from 6 different CDN sizes: 150px, 240px, 320px, 480px, 640px, and 1080px. The frontend uses srcset + sizes to let the browser pick the right one. On a Pixel 6 (1080px wide, 2.625 DPR), it loads the 1080px version, not the 2160px one — saving 60% bandwidth.

2. Video Autoplay Strategy

// Intersection Observer for video autoplay
class VideoPlaybackManager {
  private observer: IntersectionObserver;
  private activeVideo: HTMLVideoElement | null = null;

  constructor() {
    this.observer = new IntersectionObserver(
      (entries) => {
        entries.forEach((entry) => {
          const video = entry.target as HTMLVideoElement;
          
          if (entry.isIntersecting && entry.intersectionRatio > 0.5) {
            // More than 50% visible — play
            this.playVideo(video);
          } else if (this.activeVideo === video) {
            // Scrolled away — pause and reset
            video.pause();
            this.activeVideo = null;
          }
        });
      },
      { threshold: [0, 0.5, 1.0] }
    );
  }

  private async playVideo(video: HTMLVideoElement) {
    // Pause any currently playing video (only 1 at a time)
    if (this.activeVideo && this.activeVideo !== video) {
      this.activeVideo.pause();
    }
    
    this.activeVideo = video;
    video.muted = true; // Required for autoplay policy
    
    try {
      await video.play();
    } catch (e) {
      // Autoplay blocked — show play button overlay
      video.setAttribute('data-autoplay-blocked', 'true');
    }
  }
}

3. Carousel Performance with Lazy Slide Loading

// Only load carousel slides that are visible or adjacent
function CarouselMedia({ items, postId }: CarouselProps) {
  const [activeIndex, setActiveIndex] = useState(0);
  
  return (
    <div className="carousel-container overflow-hidden">
      <div 
        className="carousel-track flex transition-transform duration-300"
        style={{ transform: `translateX(-${activeIndex * 100}%)` }}
      >
        {items.map((item, index) => {
          // Only render current, previous, and next slides
          const shouldRender = Math.abs(index - activeIndex) <= 1;
          
          return (
            <div key={item.id} className="carousel-slide flex-shrink-0 w-full">
              {shouldRender ? (
                <ProgressiveImage
                  src={item.url}
                  blurHash={item.blurHash}
                  aspectRatio={item.width / item.height}
                />
              ) : (
                // Placeholder with correct dimensions
                <div style={{ aspectRatio: `${item.width}/${item.height}` }} />
              )}
            </div>
          );
        })}
      </div>
      
      {/* Dot indicators */}
      <div className="flex justify-center gap-1 mt-2">
        {items.map((_, i) => (
          <div 
            key={i}
            className={cn(
              "w-1.5 h-1.5 rounded-full transition-colors",
              i === activeIndex ? "bg-primary" : "bg-muted"
            )}
          />
        ))}
      </div>
    </div>
  );
}

4. Skeleton Loading That Prevents Layout Shift

// Skeleton that matches exact post dimensions
function PostSkeleton({ aspectRatio = 1 }: { aspectRatio?: number }) {
  return (
    <div className="animate-pulse">
      {/* Header skeleton — exact height match */}
      <div className="flex items-center gap-3 p-3 h-[56px]">
        <div className="w-8 h-8 rounded-full bg-muted" />
        <div className="flex-1">
          <div className="h-3 w-24 bg-muted rounded" />
          <div className="h-2 w-16 bg-muted rounded mt-1" />
        </div>
      </div>
      
      {/* Image skeleton — matches aspect ratio */}
      <div 
        className="bg-muted w-full" 
        style={{ aspectRatio: `1/${aspectRatio}` }} 
      />
      
      {/* Actions skeleton — exact height match */}
      <div className="p-3 h-[44px] flex gap-4">
        <div className="w-6 h-6 bg-muted rounded" />
        <div className="w-6 h-6 bg-muted rounded" />
        <div className="w-6 h-6 bg-muted rounded" />
      </div>
      
      {/* Caption skeleton */}
      <div className="px-3 pb-3 space-y-1.5">
        <div className="h-3 w-20 bg-muted rounded" />
        <div className="h-3 w-full bg-muted rounded" />
        <div className="h-3 w-2/3 bg-muted rounded" />
      </div>
    </div>
  );
}

5. Offline Support with Service Worker

// Service worker caching strategy
// sw.ts
const CACHE_NAME = 'instagram-v1';
const FEED_CACHE = 'feed-data-v1';

self.addEventListener('fetch', (event: FetchEvent) => {
  const url = new URL(event.request.url);
  
  if (url.pathname.startsWith('/api/v1/feed')) {
    // Network-first with cache fallback for feed
    event.respondWith(
      fetch(event.request)
        .then(response => {
          const clone = response.clone();
          caches.open(FEED_CACHE).then(cache => cache.put(event.request, clone));
          return response;
        })
        .catch(() => caches.match(event.request))
    );
  } else if (url.pathname.match(/\.(jpg|jpeg|webp|png)$/)) {
    // Cache-first for images (they don't change)
    event.respondWith(
      caches.match(event.request).then(cached => {
        if (cached) return cached;
        return fetch(event.request).then(response => {
          const clone = response.clone();
          caches.open(CACHE_NAME).then(cache => cache.put(event.request, clone));
          return response;
        });
      })
    );
  }
});

// Sync offline interactions when back online
self.addEventListener('sync', (event: SyncEvent) => {
  if (event.tag === 'sync-interactions') {
    event.waitUntil(syncOfflineInteractions());
  }
});

async function syncOfflineInteractions() {
  const db = await openDB('offline-queue');
  const mutations = await db.getAll('pending-mutations');
  
  for (const mutation of mutations) {
    try {
      await fetch(`/api/v1/${mutation.endpoint}`, {
        method: 'POST',
        body: JSON.stringify(mutation.payload)
      });
      await db.delete('pending-mutations', mutation.id);
    } catch (e) {
      // Will retry on next sync event
      break;
    }
  }
}

6. Accessibility for Image-Heavy Content

// Accessible post component
function FeedPost({ post }: { post: Post }) {
  return (
    <article 
      aria-label={`Post by ${post.author.username}`}
      role="article"
    >
      {/* Screen readers need context */}
      <div className="sr-only">
        {post.caption}. 
        {post.likeCount} likes. 
        {post.commentCount} comments.
        Posted {formatRelativeTime(post.createdAt)}.
      </div>
      
      <PostHeader user={post.author} />
      
      {post.mediaItems.map((media, index) => (
        <img
          key={media.id}
          src={media.url}
          // AI-generated alt text from server, with user override
          alt={media.altText || `Photo ${index + 1} by ${post.author.username}`}
          role="img"
        />
      ))}
      
      <PostActions 
        post={post}
        // Announce state changes to screen readers
        onLike={() => {
          toggleLike(post.id);
          announceToScreenReader(
            post.isLikedByMe ? 'Post unliked' : 'Post liked'
          );
        }}
      />
    </article>
  );
}

function announceToScreenReader(message: string) {
  const el = document.getElementById('sr-announcer');
  if (el) {
    el.textContent = message;
  }
}
// In root: <div id="sr-announcer" aria-live="polite" className="sr-only" />

🎯 Deep Dive: The Story Progress Bar

This is a deceptively complex component that interviewers love to probe. The progress bar needs to:

  • Animate smoothly for the duration of each slide
  • Pause when the user long-presses, video is buffering, or reply is open
  • Fill instantly for already-seen slides
  • Handle variable durations (5s for images, video duration for videos)
function StoryProgressBar({ 
  slides, 
  activeIndex, 
  isPaused, 
  durations 
}: StoryProgressBarProps) {
  const [progress, setProgress] = useState(0);
  const animationRef = useRef<number>();
  const startTimeRef = useRef<number>();
  const pausedProgressRef = useRef<number>(0);

  useEffect(() => {
    if (isPaused) {
      // Save current progress and cancel animation
      pausedProgressRef.current = progress;
      if (animationRef.current) cancelAnimationFrame(animationRef.current);
      return;
    }

    const duration = durations[activeIndex] * 1000; // ms
    startTimeRef.current = performance.now() - (pausedProgressRef.current * duration);

    function animate(now: number) {
      const elapsed = now - startTimeRef.current!;
      const newProgress = Math.min(elapsed / duration, 1);
      setProgress(newProgress);

      if (newProgress < 1) {
        animationRef.current = requestAnimationFrame(animate);
      } else {
        // Auto-advance
        onSlideComplete();
      }
    }

    animationRef.current = requestAnimationFrame(animate);
    return () => {
      if (animationRef.current) cancelAnimationFrame(animationRef.current);
    };
  }, [activeIndex, isPaused]);

  // Reset progress when slide changes
  useEffect(() => {
    setProgress(0);
    pausedProgressRef.current = 0;
  }, [activeIndex]);

  return (
    <div className="flex gap-0.5 px-2 pt-2" role="progressbar">
      {slides.map((_, index) => (
        <div key={index} className="flex-1 h-0.5 bg-white/30 rounded-full overflow-hidden">
          <div
            className="h-full bg-white rounded-full"
            style={{
              width: index < activeIndex 
                ? '100%'                              // Seen slides: full
                : index === activeIndex 
                  ? `${progress * 100}%`              // Current: animated
                  : '0%',                             // Future: empty
              transition: index < activeIndex ? 'none' : undefined
            }}
          />
        </div>
      ))}
    </div>
  );
}

🔥 Real-world war story: Instagram's original story progress bar used CSS animations with animation-play-state: paused. This worked great — until iOS Safari. Safari would restart CSS animations from 0% when resuming instead of continuing from the paused position. The fix was switching to requestAnimationFrame for precise control, which is what the code above uses.


📊 Performance Budget

Metric

Target

How We Achieve It

First Contentful Paint

< 1.5s

SSR shell + inline critical CSS + BlurHash placeholders

Largest Contentful Paint

< 2.5s

Preload first 3 images, priority fetch for viewport media

Cumulative Layout Shift

< 0.05

Aspect ratio from API, skeleton dimensions match real content

Interaction to Next Paint

< 100ms

Optimistic updates, heart animation triggers on touch start

JS Bundle Size

< 200KB gzipped

Route-based code splitting, lazy load stories/reels viewer

Scroll FPS

60fps

Virtualized list, GPU-composited layers for images, will-change

Memory usage

< 150MB

Revoke object URLs, limit video preload to 1, image cache eviction


🧠 Summary: What Makes This a 5/5 Answer

Rubric

What We Covered

Requirements

Scoped to feed + stories, identified both functional and non-functional with specific metrics

Architecture

Full component tree with virtualization, state machine for stories, media pipeline

Data Model

Normalized store with optimistic mutations, offline queue, media metadata for layout

API Design

REST + WebSocket hybrid, cursor pagination, server-driven UI hints, optimistic interaction pattern

Optimizations

Progressive image loading (BlurHash → thumb → full), video autoplay manager, carousel lazy loading, service worker caching, accessibility with AI alt text, performance budget

Real-world depth

5 production war stories from Instagram, Twitter, Snapchat covering CLS bugs, virtualizer failures, touch event bugs, CSS animation quirks

The key differentiator in this answer is the media pipeline. Most candidates talk about "rendering images" generically. A 5/5 answer discusses BlurHash placeholders, srcset with art direction, aspect ratio reservation, and the three-stage loading pipeline. This is what Instagram actually does — and what interviewers at Meta are looking for.

Next up in this series: Design an Infinite Scroller — where we'll deep-dive into virtual scrolling implementations, bidirectional infinite scroll, and the scroll restoration problem that drove the Chrome team to create a new API.

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 (0)

Sign in to leave a comment.

No comments yet. Be the first to comment.

Table of Contents

  • 📋 Step 1: Requirements Exploration
  • Clarifying Questions I'd Ask
  • Functional Requirements (Core Scope)
  • Non-Functional Requirements
  • 🏗️ Step 2: Architecture / High-Level Design
  • Component Architecture
  • Why This Architecture Wins
  • The Media Rendering Pipeline
  • 📊 Step 3: Data Model
  • Client-Side State Architecture
  • Why Normalization Matters Here
  • 🔌 Step 4: Interface Definition (API Design)
  • Feed API
  • Interaction APIs (Optimistic Pattern)
  • Real-time Updates via WebSocket
  • ⚡ Step 5: Optimizations
  • 1. Responsive Image Serving with Art Direction
  • 2. Video Autoplay Strategy
  • 3. Carousel Performance with Lazy Slide Loading
  • 4. Skeleton Loading That Prevents Layout Shift
  • 5. Offline Support with Service Worker
  • 6. Accessibility for Image-Heavy Content
  • 🎯 Deep Dive: The Story Progress Bar
  • 📊 Performance Budget
  • 🧠 Summary: What Makes This a 5/5 Answer

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.