Design a Social Feed

Hard

Designing a social feed (like Twitter, LinkedIn, or Instagram) is a classic frontend system design question. It tests your ability to handle infinite scrolling, real-time updates, optimistic interactions (like/comment), media rendering, and virtualization for thousands of items while maintaining a smooth 60fps scrolling experience.

Interactive Visualization

Key Points

  • Virtual scrolling is essential: only render items visible in the viewport plus a buffer
  • Feed items have variable heights, requiring dynamic measurement and position caching
  • New posts can be prepended without disrupting the current scroll position
  • Optimistic updates for likes/comments make interactions feel instant
  • Media (images, videos) needs lazy loading with aspect ratio preservation to prevent CLS
  • Intersection Observer triggers both lazy loading and scroll-position-based pagination
  • Feed state separates the item list (IDs) from item data (normalized entities)
  • Skeleton screens improve perceived performance during initial load and pagination

Code Examples

Virtualized Feed with Dynamic Heights

// Virtual list: only renders visible items
interface VirtualItem {
  id: string
  index: number
  offsetTop: number
  height: number
}

function useVirtualFeed(
  items: FeedItem[],
  containerRef: RefObject<HTMLElement>,
  estimatedItemHeight = 300
) {
  const [scrollTop, setScrollTop] = useState(0)
  const [containerHeight, setContainerHeight] = useState(0)
  const heightCache = useRef<Map<string, number>>(new Map())

  // Calculate positions from cached heights
  const virtualItems = useMemo(() => {
    let offset = 0
    return items.map((item, index) => {
      const height = heightCache.current.get(item.id) ?? estimatedItemHeight
      const virtualItem: VirtualItem = {
        id: item.id,
        index,
        offsetTop: offset,
        height,
      }
      offset += height
      return virtualItem
    })
  }, [items, estimatedItemHeight])

  const totalHeight = virtualItems.length > 0
    ? virtualItems[virtualItems.length - 1].offsetTop +
      virtualItems[virtualItems.length - 1].height
    : 0

  // Only render items in viewport + buffer
  const overscan = 3
  const visibleItems = virtualItems.filter(item => {
    const itemBottom = item.offsetTop + item.height
    const viewTop = scrollTop - overscan * estimatedItemHeight
    const viewBottom = scrollTop + containerHeight + overscan * estimatedItemHeight
    return itemBottom > viewTop && item.offsetTop < viewBottom
  })

  // Measure actual heights after render
  const measureItem = useCallback((id: string, height: number) => {
    if (heightCache.current.get(id) !== height) {
      heightCache.current.set(id, height)
    }
  }, [])

  return { visibleItems, totalHeight, measureItem }
}

Virtual scrolling keeps the DOM node count constant regardless of feed length. Dynamic height measurement with a cache handles variable-height items like posts with images, text, and embedded content.

New Post Insertion Without Scroll Jump

// Insert new posts at top without disrupting scroll position
function useFeedUpdates(ws: WebSocketManager) {
  const [items, setItems] = useState<FeedItem[]>([])
  const [pendingCount, setPendingCount] = useState(0)
  const pendingItems = useRef<FeedItem[]>([])
  const scrollRef = useRef<HTMLElement>(null)

  useEffect(() => {
    return ws.subscribe('new-post', (data) => {
      const post = data as FeedItem

      // If user is scrolled down, batch new posts
      const isAtTop = (scrollRef.current?.scrollTop ?? 0) < 100

      if (isAtTop) {
        // User is at top: insert immediately
        setItems(prev => [post, ...prev])
      } else {
        // User is reading: show "N new posts" banner
        pendingItems.current = [post, ...pendingItems.current]
        setPendingCount(prev => prev + 1)
      }
    })
  }, [ws])

  const showNewPosts = useCallback(() => {
    setItems(prev => [...pendingItems.current, ...prev])
    pendingItems.current = []
    setPendingCount(0)
    scrollRef.current?.scrollTo({ top: 0, behavior: 'smooth' })
  }, [])

  return { items, pendingCount, showNewPosts, scrollRef }
}

// UI: "3 new posts" banner
function FeedHeader({ pendingCount, onShowNew }: FeedHeaderProps) {
  if (pendingCount === 0) return null
  return (
    <button
      onClick={onShowNew}
      className="sticky top-0 z-10 w-full bg-blue-500 text-white py-2"
    >
      {pendingCount} new {pendingCount === 1 ? 'post' : 'posts'}
    </button>
  )
}

Inserting content above the viewport causes a scroll jump. The solution: if the user is at the top, insert immediately. Otherwise, queue new items behind a clickable banner that the user activates when ready.

Feed Architecture Overview

// Complete feed architecture
//
// ┌─────────────────────────────────────────┐
// │              Feed Shell                  │
// │  ┌───────────────────────────────────┐  │
// │  │  NewPostsBanner (sticky)          │  │
// │  ├───────────────────────────────────┤  │
// │  │  VirtualizedList                  │  │
// │  │  ┌─────────────────────────────┐  │  │
// │  │  │  FeedItem                    │  │  │
// │  │  │  ├── AuthorHeader            │  │  │
// │  │  │  ├── PostContent             │  │  │
// │  │  │  ├── MediaGallery (lazy)     │  │  │
// │  │  │  ├── EngagementBar           │  │  │
// │  │  │  └── CommentPreview          │  │  │
// │  │  └─────────────────────────────┘  │  │
// │  │  ┌─────────────────────────────┐  │  │
// │  │  │  FeedItem                    │  │  │
// │  │  │  └── ...                     │  │  │
// │  │  └─────────────────────────────┘  │  │
// │  │  LoadMoreSentinel (observer)    │  │  │
// │  └───────────────────────────────────┘  │
// └─────────────────────────────────────────┘

// State architecture
interface FeedState {
  // Normalized entities
  posts: Record<string, Post>
  users: Record<string, User>
  comments: Record<string, Comment>
  // Ordered list of visible post IDs
  feedOrder: string[]
  // Pagination
  cursor: string | null
  hasMore: boolean
  // Real-time
  pendingPostIds: string[]
  // UI
  expandedComments: Set<string>
}

// Key architectural decisions:
// 1. Normalized state: update user avatar in one place
// 2. Virtual list: handle 10K+ items without DOM bloat
// 3. Cursor pagination: no offset drift on active feeds
// 4. Optimistic likes: instant UI, rollback on failure
// 5. Image lazy loading: Intersection Observer + blur placeholder
// 6. WebSocket for new posts, polling fallback every 30s

The complete feed architecture combines normalized state, virtual scrolling, cursor pagination, real-time updates, and lazy-loaded media into a coherent system that scales to millions of users.

Common Mistakes

  • Rendering all feed items in the DOM instead of virtualizing, causing jank at 1000+ items
  • Inserting new posts at the top while the user is reading, causing disorienting scroll jumps
  • Not preserving scroll position when navigating to a detail view and back
  • Loading full-resolution images instead of responsive srcset with blur placeholders
  • Using a flat array for feed state instead of normalized entities, causing stale data

Interview Tips

  • Start by sketching the component tree: Shell > VirtualList > FeedItem > sub-components
  • Discuss the scroll position preservation problem when inserting new items
  • Mention virtual scrolling and dynamic height measurement as key technical challenges
  • Explain the normalized state model and why entity deduplication matters for feeds
  • Address real-time: WebSocket for new posts, optimistic updates for interactions

Related Concepts