Social & Feed
Design Reddit-Style Comments
01
The problem
The problem
Under every post sits a conversation shaped like a tree. People reply to the post, reply to replies, and argue twelve levels deep. Everyone votes on everything, and a reader can sort the whole tree by best, top, new or controversial. A popular post can collect tens of thousands of comments within a few hours, while millions of people read it.
Storing comments is easy; each one is a row with a parent pointer. The hard part is the read. A reader wants the most relevant few hundred comments, arranged as a tree, sorted at every level, with "load more" links where branches were cut, and they want it on screen in a fraction of a second. Doing that on a thread with 40,000 comments, while votes keep changing the order, is the real problem.
How do you return a sorted, pruned slice of a large and constantly changing tree in under 200 ms, without loading the whole tree into memory for every reader?
02
Scope
Scope
- Post a comment on a post or as a reply to any comment, with no fixed limit on depth.
- Upvote, downvote or clear a vote on any comment; each user has at most one vote per comment.
- Read a thread sorted by best, top, new, old or controversial, with the order applied among siblings at every level.
- Load more siblings or a deeper branch on demand, through "more replies" and "continue this thread" links.
- Edit and delete your own comments; moderators can remove comments and lock threads.
- Collapse a branch, and link directly to one comment with its parent context.
- First page of a thread renders in p99 under 200 ms, including threads with 50,000 comments.
- A new comment appears for its author immediately and for everyone else within a few seconds.
- Vote counts may lag by a few seconds, but a user's own vote must always show correctly.
- Comments are never lost once acknowledged.
- Read-heavy: about 100 comment reads for every comment written.
Clarify how many comments the first page should show, how deep it should go, and whether vote totals must be exact. The first two set your fetch budget. The third tells you whether you can aggregate votes in batches, which is the difference between a simple design and a painful one. Also ask if live updates are required, or whether a reload is acceptable.
03
Back-of-envelope numbers
Back-of-envelope numbers
Assume 90 million daily readers, 7 million new comments a day and 70 million votes a day. Readers open about 8 threads each, and each thread view returns roughly 150 comments on its first page.
| Quantity | Working | Result |
|---|---|---|
| Thread views per day | 90M x 8 | 720M |
| Thread view rate | 720M / 86,400 s, peak 3x | 8,300 / s average, 25,000 / s peak |
| Comments served | 25,000 x 150 at peak | about 3.75M comments / s |
| Comment writes | 7M / 86,400 s, peak 4x | 80 / s average, 320 / s peak |
| Vote writes | 70M / 86,400 s, peak 4x | 810 / s average, 3,200 / s peak |
| Comment row | ids, path, scores, flags, about 400 B body on average | about 600 B |
| Comment storage per year | 7M x 365 x 600 B | about 1.5 TB, 4.6 TB with 3 replicas |
| Vote records per year | 70M x 365 x 24 B | about 610 GB |
| Largest thread | live event megathread | 50,000+ comments, 500 reads / s on one post |
Storage is small and writes are modest. The pressure is millions of comments read per second, concentrated on a few hundred hot threads. A cached first page for each hot thread and sort order absorbs most of it; the database only needs to serve the long tail and the "load more" requests.
04
Endpoints
Endpoints
Nodes come back in display order with a parent id and depth. The client rebuilds the tree in one pass, and the server can stream nodes without holding a nested structure in memory.
A cursor records the parent, the sort and how many siblings were already shown, so expanding a branch never needs the server to remember the reader's session.
The client sends the final value it wants. Two retried requests cannot accidentally cancel each other, which a toggle endpoint would allow.
05
Architecture overview
Architecture overview
flowchart LR
R["Reader"] --> GW["API gateway"]
GW --> TS["Thread service"]
TS --> TC[("Redis: rendered first pages")]
TS --> PG[("Comments DB, sharded by post")]
W["Writer"] --> GW
GW --> CS["Comment service"]
CS --> PG
CS -. "comment.created" .-> K[["Kafka"]]
GW --> VS["Vote service"]
VS --> VD[("Votes by user")]
VS -. "vote.changed" .-> K
K --> AG["Score aggregator"]
AG --> PG
AG -. "invalidate" .-> TC
K --> NT["Reply notifications"]Three services share one comment store. The thread service only reads: it serves first pages from a Redis cache keyed by post and sort, and builds them from the database on a miss. The comment service handles writes and edits. The vote service records each user's vote and publishes the change, but never touches comment rows directly.
Comment rows are sharded by post_id, so an entire thread lives on one shard and a tree query never crosses machines. Votes are stored separately, keyed by user and comment, which is the lookup you need to show "my vote" and to reject duplicates. A score aggregator consumes vote events, adds them up per comment in short windows, writes new totals and sort scores in batches, and invalidates the affected cache entries.
| Component | Holds | Why it is separate |
|---|---|---|
| Comments DB | rows with parent, path, depth, counts and per-sort scores | source of truth, one thread per shard |
| Votes store | (user, comment) to value | high write volume, different key |
| Thread cache | first page per post and sort, as a serialised node list | absorbs hot threads |
| Score aggregator | in-memory counters per comment for a few seconds | turns 3,200 votes / s into far fewer row updates |
| Kafka | comment and vote events | feeds scores, notifications, search and moderation |
06
Deep dive: fetching a pruned tree
Deep dive: fetching a pruned tree
What not to do
The tempting version is to load every comment for the post, build the tree in memory and cut it. For a thread of 40,000 comments at 600 bytes each, that is 24 MB read and sorted for every page view, most of which the reader never sees. The recursive alternative, fetching children of each node one query at a time, turns one page into hundreds of round trips. Both fall over on exactly the threads people care about.
What the page needs is narrower: the best few children of the root, the best few children of each of those, and so on, until a total budget is spent. The fetch should touch roughly the rows it returns.
Data model
The parent pointer plus an index on (post_id, parent_id, score) makes "the best k children of this node" a short index range scan. The materialised path answers the other common question, "this comment and everything under it", with a prefix match, which is what deep links and "continue this thread" use. Keeping a pre-computed score column per sort order is a deliberate denormalisation: sorting on the fly would mean reading every sibling to sort them.
Level-by-level expansion with a budget
flowchart TD
A["Level 0: best 40 children of the post"] --> B["Level 1: best 8 children of each, one batched query"]
B --> C["Level 2: best 4 children of each"]
C --> D["Levels 3 to 5: best 2 children of each"]
D --> E{"Budget of 150 nodes spent?"}
E -->|yes| F["Stop, emit more and deeper stubs"]
E -->|no| G["Widen the top level by 10 and repeat"]
G --> B
F --> H["Order nodes depth first for display"]Each level is a single query, no matter how many parents it covers. In Postgres the idiomatic form is a lateral join: for every parent id from the previous level, take the first k rows from the parent index. The planner turns that into one short index scan per parent on the same shard.
The shrinking fan-out per level, 40 then 8 then 4 then 2, mirrors how people read: they scan many top-level opinions, read the best reply or two to each, and rarely go deeper unless they choose to. Six levels with those limits reach about 150 nodes in five or six queries, typically 15 to 30 ms on a warm shard. A window function that ranks rows per parent works too, but it has to read all siblings at a level before filtering, which hurts on wide threads.
Stubs and cursors
| Stub | When it appears | What the cursor holds |
|---|---|---|
| More replies | a node has more children than its level allowed | parent id, sort, offset or last score seen |
| Continue this thread | the depth limit was reached but children exist | the comment to use as a new root |
| More top-level | the post has more root comments | sort and position in the root list |
Counts on the stubs come from child_count, maintained when replies are written, so the page can say "212 more replies" without counting rows. Because scores move while someone reads, a cursor that stores the last score and id seen is more robust than a numeric offset; it may skip or repeat a comment that jumped, which readers accept.
Ranking the siblings
"Top" is just ups minus downs. "Best" should not be, because a comment with 3 upvotes and no downvotes would beat one with 400 up and 40 down. The usual fix is the lower bound of a Wilson score interval on the share of upvotes: it asks how good the comment is likely to be, given how few votes it has. Few votes means a wide interval and a low bound, so new comments rise only as evidence accumulates. "Controversial" rewards many votes split close to evenly. All three are recomputed by the aggregator whenever counts change, never at read time.
When a comment with replies is deleted, removing the row would orphan its whole subtree. Keep it as a tombstone that renders as "[deleted]", keep its children attached, and hard-delete only leaves. Treat removal by moderators the same way, while hiding the body from everyone except moderators.
07
Trade-offs
Trade-offs
Parent pointers drive per-level fetches, paths drive subtree queries. Inserts write one row with a path built from the parent's. Comments never move between parents, so the classic weakness of paths, rewriting them on a move, does not apply.
Fits a tree that never reshapesA closure table stores every ancestor and descendant pair, so a reply 15 levels deep adds 15 rows. Nested sets make subtree reads fast but renumber large parts of the tree on every insert, which is fatal for a busy thread.
Too costly on writeEvery sort becomes an index scan. The price is extra columns and index maintenance on each score update, which batching keeps cheap.
Reads stay cheapNo extra storage, but each level must read all siblings to rank them. On a top-level list of 9,000 comments that is the whole problem again.
Breaks on big threadsEach vote is recorded at once in the votes store, but counters on the comment row are updated in batches. A comment receiving 200 votes a second costs one row update per window, not 200.
Counts lag by secondsExact and immediate, and it turns a popular comment into a lock hotspot that slows every other write to its shard.
Row contentionThe first page per post and sort is what almost every reader requests, so caching it whole turns most views into one Redis read. Personal parts, such as your own votes, are merged in afterwards from the votes store.
One read for most viewsFiner invalidation, but every view still has to work out which ids belong on the page, which is the expensive part. Useful as a second layer for bodies.
ComplementaryThe comment service commits the row and publishes an event. Reply notifications, indexing and spam scoring happen downstream, so a slow consumer cannot delay posting.
Fast writesA spam comment may be live for a few seconds before the classifier removes it. For high-risk communities, hold comments from new accounts for a synchronous check instead.
Tune per community08
Failure modes
Failure modes
A breaking news post draws 500 reads and 30 comments per second. Every new comment invalidates the cached page, every reader then misses, and they all rebuild the same tree at once.
Rebuild the cached page on a short timer, every 5 to 10 seconds for hot threads, rather than on each write, and use single-flight so one request rebuilds while others get the previous copy. Add read replicas for the shard holding the hot post.Two users trade one-line replies for an hour, or a bot does it on purpose. Paths grow long, and naive recursive rendering overflows the client.
Cap rendered depth per request and emit a "continue this thread" stub. Store paths with fixed-width encoded ids so their length is predictable, and rate-limit replies within one branch.A coordinated group or a bot farm downvotes every comment in a thread within a few minutes, burying legitimate replies.
Score each vote's trust from account age, history and network signals before it reaches the aggregator, discount votes arriving from a linked source in a burst, and add small random noise to displayed counts so manipulators cannot easily measure their effect.A deploy stalls the score consumer. Votes are safely recorded, but scores and orderings freeze, and users report that their votes "do nothing".
The client shows the user's own vote from local state and the votes store, so the experience stays correct for them. Alert on consumer lag, and make aggregation idempotent so a replay after restart cannot double count.A user posts, the page reloads from a cached copy built before the write, and their comment is missing. They post again, creating a duplicate.
Insert the returned node on the client without a refetch, read your own recent comments from the primary for a short window, and dedupe on the client token so a second post with the same token returns the first.A cleanup job hard-deletes old removed comments, and thousands of replies lose their parent. Tree queries now return fragments that cannot be placed.
Only hard-delete comments with no live children. Everything else becomes a tombstone, and a database constraint or job check enforces the rule.09
How to handle the interview
How to handle the interview
Say early that storage is easy and the read is hard. The interviewer wants to see you go straight to "how do I fetch a sorted, cut-down tree cheaply".
Compare tree models out loud. Parent pointer, materialised path, closure table and nested sets each have a clear cost. Pick based on the fact that comments never move.
Give the fetch a budget. Concrete per-level limits and a total node cap show you have thought about what the page actually displays.
Explain why "best" is not "top". A short argument for a confidence-based score, such as the Wilson lower bound, is one of the most common follow-ups.
Separate the vote record from the vote count. One is per user and must be exact; the other can be aggregated and slightly behind. Saying that resolves most consistency questions.
Shard by post and say why. One thread on one shard keeps every tree query local. Then handle the obvious objection, a single huge thread, with caching and replicas.
Mention deletion semantics. Tombstones that keep the tree intact are a small detail that shows you have seen this kind of system break.
10
Growing the design
Growing the design
One database, a parent pointer, and the application builds the tree in memory. With a few hundred comments per post this is the right answer, and it is easy to change later.
Add score columns per sort and an index on parent plus score, then switch to budgeted level-by-level fetching with "more" stubs.
Redis holds the first page per post and sort, votes move to their own store with a stream aggregator, and notifications go through a queue.
Comments move to many shards keyed by post id, hot shards get replicas, and the votes store, which is keyed by user, scales independently.
New comments on a hot thread are pushed to open pages over a stream, timer-based rebuilds replace write-triggered invalidation, and trust scoring filters votes before they count.
Watch and read
Further watching and reading
Try next
Free to read · better with landed
Whiteboard this with landed
landed runs it as a live system design round on the whiteboard and grades your trade-offs.