renderpx
Theme: auto
System Design

Reddit

Feed + nested comments + voting + real-time feel. How I'd architect the frontend: which patterns handle which surfaces, where state lives, and the tradeoffs I'd make.

The Challenge

Reddit's frontend looks deceptively simple: it's a list of posts and a tree of comments. The actual complexity is in four places:

  • The feed is paginated and ranked. Hot/new/top sorts produce different orderings; the user expects infinite scroll without the feed jumping or re-ordering under them.
  • Comment threads are recursive trees. A single post can have thousands of comments nested 10+ levels deep. Naive recursion kills render performance at scale.
  • Voting must feel instant. Every vote is an optimistic mutation against a server that may reject it. The same post appears in multiple query cache entries (feed + post detail), so you have to update both without introducing inconsistency.
  • Real-time feel without WebSockets. Reddit historically used polling (not WebSockets) for vote counts and new comments. The question is what interval is acceptable, and what actually warrants a persistent connection.

The decisions below are the ones that actually matter. I'll skip the boilerplate.

Data Model

Getting the shape right up front determines how simple or painful every downstream decision is. Three things worth calling out:

  • userVote: 1 | 0 | -1 lives on both Post and Comment. This is the user's current vote state, not a separate entity. It makes optimistic updates trivial: toggle the value and adjust score arithmetically.
  • replies: Comment[] makes the tree recursive. The server sends the full subtree up to a depth limit (~3-5 levels). "Load more replies" is a separate fetch appended to that node.
  • depth is a derived field. The API doesn't need to return it - you compute it as you traverse the tree. But keeping it on the flat model is useful for virtualized rendering.
ts
Loading...

Architecture Map

Each surface in Reddit maps to a specific pattern or framework decision. This is the full picture before diving into each:

SurfacePattern / FrameworkKey decision
FeedInfinite Scroll + React QueryCursor pagination; virtualize past ~50 posts
CommentsRecursive component + optional virtualizationCollapse state is local; flatten tree for large threads
VotingOptimistic Updates + Cache InvalidationUpdate both feed cache and post-detail cache on mutate
Live vote countsPolling vs WebSockets60s poll is fine; WebSocket only for chat / r/place
Feed sort/filterURL stateSort in searchParams; shareable and survives refresh
Auth / userState Architecture (global)Zustand store; read by vote buttons, navbar, composer
Post / comment formForm ValidationReact Hook Form; lazy-load rich text editor
Initial page loadRendering Strategy (SSR)SSR first page of feed for SEO; hydrate with React Query
Bundle sizePerformance ArchitectureRoute splitting automatic; lazy-load editor, media player

Feed Architecture

The feed is cursor-paginated, not offset. Offset pagination breaks when posts are inserted or promoted between pages (the user sees duplicates or skips). A cursor points to the last-seen item and the server returns the next batch from there.

React Query's useInfiniteQuery manages pages as a flat list you concatenate for rendering. The query key includes both subreddit and sort. Changing sort invalidates the cache and starts fresh, which is the correct behavior (a "new" sort fetch is not the same data as "hot").

tsx
Loading...

When to virtualize

For a standard subreddit browsing session, a user rarely sees more than 30-40 posts before navigating to a post detail. Virtualization is worth adding when you have evidence users are scrolling deep - or if post cards are heavy (images, video previews). Add it after you've profiled, not before.

staleTime: 60_000 prevents the feed from re-sorting itself when the user navigates back. Without it, the first query after 0ms of staleness re-fetches, and "hot" posts in different positions make the list jump. One minute of staleness is a deliberate UX choice.

Comment Threads

Collapse state is local. This is the first decision people get wrong: they reach for Zustand or URL params for comment collapse. Don't. It's ephemeral UI state owned by one component instance. When the user refreshes, comments should all start expanded. Local useState is correct.

tsx
Loading...

The recursive approach works up to ~200 visible comment nodes before you start seeing layout jank on lower-end devices. For threads that regularly exceed that (AMAs, viral posts) you need to flatten the tree and virtualize.

Flatten tree for virtualizationts
Loading...

The depth limit problem

The server typically returns comments up to depth 5-7. "Load more replies" at deep nodes is a separate API call that appends into the tree. When you flatten for virtualization, re-inserting those new nodes at the right index is the tricky part. Keep the flat array derived (not a separate state) so re-deriving it on data change is always correct.

Voting System

Voting is the most complex mutation in Reddit because the same post lives in multiple query cache entries simultaneously. A post appears in the feed query (['feed', subreddit, sort]) and in the post detail query (['post', postId]). If you only optimistically update one, the other shows stale vote state when the user navigates.

The solution is to pass the queryKey to the vote hook so the caller decides which cache entry to update. In practice, the feed card passes the feed key; the post detail passes the detail key. Both fire the same mutation function; the cache update logic handles both shapes.

tsx
Loading...

Why not normalize?

A normalized cache (Apollo-style) would automatically update every reference to the same post across all queries. That's appealing. But React Query's flat cache is simpler and the dual-shape applyVote function handles it cleanly. I'd add normalization only if I had many more surfaces that share the same post entity - and even then I'd use TanStack Query's queryClient.setQueriesData with a predicate before reaching for a third-party normalized cache.

Real-Time Strategy

Reddit does not use WebSockets for the feed or comments in its standard experience. This surprises people. The actual strategy is deliberate polling - and it's the right call for most surfaces:

  • Vote counts on the feed: 60s poll while the page is visible. Users tolerate stale vote counts; a number jumping from 847 to 852 while they're reading doesn't matter.
  • New comments on a post detail: 30s poll. A "X new comments - click to load" banner is the right UX rather than auto-inserting comments and shifting the user's reading position.
  • Live notifications / inbox: 60s poll. For most users this is fine; power users can enable browser notifications via the Push API.
  • r/place, live threads, chat: WebSocket. These are the minority of surfaces where sub-second latency actually matters.
tsx
Loading...

The discipline here is refetchIntervalInBackground: false. Without it, every open tab polls continuously even when the user isn't looking at it. At Reddit's scale, that's a meaningful server cost.

State Architecture

The most common mistake I see in Reddit-like apps is over-centralizing state. Everything ends up in a Zustand store or global Context - feed data, comment collapse, vote state, form values. The result is a store that becomes a dumping ground and components that re-render whenever anything in it changes.

The right approach is to put each piece of state in the layer that owns it:

tsx
Loading...

The test

For any piece of state, ask: does it need to survive navigation? Does more than one distant component need it? If yes to either - lift it. URL for navigation-safe data; React Query for server data; Zustand for cross-cutting client state like auth. If no - keep it local.

Performance

Reddit has two distinct performance problems: initial load (LCP) and scroll performance. They require different tools.

For LCP: SSR the first page of the feed and the post detail. The above-the-fold content needs to be in the HTML - both for SEO (Google indexes the first page of each subreddit) and for perceived performance (no loading spinner on the first paint). Next.js App Router with React Query's server prefetching handles this cleanly.

For scroll: the rich text editor is the single biggest bundle hit (~200kb gzipped for a full Quill or TipTap install). Lazy-load it behind a dynamic import that only triggers when the user opens the compose modal. The rest of scroll performance comes from virtualization and stable keys.

tsx
Loading...

Prefetch on hover

The last snippet - prefetching the post detail route on mouse enter - is one of the highest-ROI performance wins available in Next.js. It makes navigation feel instant because the JS bundle and initial data are already loaded before the user clicks. Use it on any link the user is likely to navigate to.

Building Blocks

Every piece of this design maps to a pattern or framework covered in detail elsewhere:

What I'd Do Differently

  • I'd skip the dual-shape applyVote helper and use queryClient.setQueriesData with a predicate instead. Setting data on all matching queries in one call is cleaner than passing the specific queryKey down through props. The predicate lets you match any query whose key starts with ['feed'] or equals ['post', postId] in one sweep.
  • I'd add a "new comments available" banner before auto-polling inserts comments. Automatically inserting new comments while a user is mid-read shifts their scroll position. The correct UX is to show a dismissible banner ("12 new comments") and let the user decide when to load them - similar to Twitter's "See X new Tweets."
  • I would not virtualize comment threads by default. It adds real complexity (tree flattening, index management for "load more" insertions, scroll restoration). I'd ship the recursive component, profile on a real device with a high-comment thread, and only reach for virtualization if I had data showing jank. Most posts don't have 500+ visible comments.
  • I'd use URL hash for active comment. Deep-linking to a specific comment (/comments/abc#comment-xyz) is a Reddit feature that requires the target comment to be scrolled into view on load. This is non-trivial with a virtualized list - you have to scroll the virtualizer to the index before the user sees the page. With the recursive DOM approach, the browser handles it natively for free.

Data Fetching & Sync → · State Architecture → · All patterns