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 | -1lives on bothPostandComment. This is the user's current vote state, not a separate entity. It makes optimistic updates trivial: toggle the value and adjustscorearithmetically.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.depthis 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.
Architecture Map
Each surface in Reddit maps to a specific pattern or framework decision. This is the full picture before diving into each:
| Surface | Pattern / Framework | Key decision |
|---|---|---|
| Feed | Infinite Scroll + React Query | Cursor pagination; virtualize past ~50 posts |
| Comments | Recursive component + optional virtualization | Collapse state is local; flatten tree for large threads |
| Voting | Optimistic Updates + Cache Invalidation | Update both feed cache and post-detail cache on mutate |
| Live vote counts | Polling vs WebSockets | 60s poll is fine; WebSocket only for chat / r/place |
| Feed sort/filter | URL state | Sort in searchParams; shareable and survives refresh |
| Auth / user | State Architecture (global) | Zustand store; read by vote buttons, navbar, composer |
| Post / comment form | Form Validation | React Hook Form; lazy-load rich text editor |
| Initial page load | Rendering Strategy (SSR) | SSR first page of feed for SEO; hydrate with React Query |
| Bundle size | Performance Architecture | Route 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").
When to virtualize
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.
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.
Why not normalize?
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.
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:
The test
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.
Prefetch on hover
Building Blocks
Every piece of this design maps to a pattern or framework covered in detail elsewhere:
Patterns
What I'd Do Differently
- I'd skip the dual-shape
applyVotehelper and usequeryClient.setQueriesDatawith a predicate instead. Setting data on all matching queries in one call is cleaner than passing the specificqueryKeydown 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
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
useStateis correct.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.
The depth limit problem