← System design

Social & Feed

Design Reddit-Style Comments

A comment section is a tree that strangers keep growing and voting on. Writing a reply is trivial; the work is in serving a sorted, cut-down view of a tree with tens of thousands of nodes to millions of readers, fast, while the order underneath it keeps shifting.
Core: budgeted level-by-level tree fetch25,000 thread views a second at peakRead-heavyEventually consistent vote counts

01

The problem

01

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.

The question to answer

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

02

Scope

Functional
  • 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.
Non-Functional
  • 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.
Ask before you draw

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

03

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.

QuantityWorkingResult
Thread views per day90M x 8720M
Thread view rate720M / 86,400 s, peak 3x8,300 / s average, 25,000 / s peak
Comments served25,000 x 150 at peakabout 3.75M comments / s
Comment writes7M / 86,400 s, peak 4x80 / s average, 320 / s peak
Vote writes70M / 86,400 s, peak 4x810 / s average, 3,200 / s peak
Comment rowids, path, scores, flags, about 400 B body on averageabout 600 B
Comment storage per year7M x 365 x 600 Babout 1.5 TB, 4.6 TB with 3 replicas
Vote records per year70M x 365 x 24 Babout 610 GB
Largest threadlive event megathread50,000+ comments, 500 reads / s on one post
Reads dominate, and they cluster

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

04

Endpoints

GET/v1/posts/{post_id}/thread?sort=best&depth=6&limit=150First page of the comment tree
{ "post_id": "p_7mzq2", "sort": "best", "total_comments": 18342, "nodes": [ { "id": "c_k91", "parent": null, "depth": 0, "author": "rainshadow", "score": 4120, "my_vote": 1, "body_html": "<p>...</p>" }, { "id": "c_k9f", "parent": "c_k91", "depth": 1, "score": 913, "my_vote": 0 }, { "kind": "more", "parent": "c_k91", "hidden": 212, "cursor": "m.c_k91.best.8" }, { "kind": "deeper", "under": "c_x40", "cursor": "d.c_x40" } ], "top_cursor": "m.root.best.40" // next top-level comments }
GET/v1/posts/{post_id}/thread/more?cursor=m.c_k91.best.8Expand a cut branch
// same node shape; returns the next siblings plus a shallow slice of their replies // a "d." cursor instead starts a fresh tree rooted at that comment
POST/v1/posts/{post_id}/commentsWrite a comment or reply
{ "parent_id": "c_k9f", // omit for a top-level comment "body_md": "Source for that number? The report I read said 31%.", "client_token": "9d0e1b7c" // dedupes double taps } // 201 with the full node, so the client inserts it without a refetch
POST/v1/comments/{comment_id}/voteSet this user's vote
{ "value": -1 } // 1, 0 or -1; setting a value is idempotent, so retries are safe
A flat list, not nested JSON

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.

Cursors carry position

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.

Votes are set, not toggled

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

05

Architecture overview

Read path, write path and vote pipelineDiagram
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.

ComponentHoldsWhy it is separate
Comments DBrows with parent, path, depth, counts and per-sort scoressource of truth, one thread per shard
Votes store(user, comment) to valuehigh write volume, different key
Thread cachefirst page per post and sort, as a serialised node listabsorbs hot threads
Score aggregatorin-memory counters per comment for a few secondsturns 3,200 votes / s into far fewer row updates
Kafkacomment and vote eventsfeeds scores, notifications, search and moderation

06

Deep dive: fetching a pruned tree

06

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

CREATE TABLE comments ( post_id bigint, id bigint, -- time-ordered id, so "new" sorts by id parent_id bigint, -- 0 for top level path text, -- ancestor ids joined, e.g. 0012.00a9.00c4 depth smallint, child_count int, ups int, downs int, s_best real, s_top int, s_contro real, state smallint, -- live, deleted, removed PRIMARY KEY (post_id, id) ); CREATE INDEX by_parent_best ON comments (post_id, parent_id, s_best DESC); CREATE INDEX by_parent_top ON comments (post_id, parent_id, s_top DESC);

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

Building the first pageDiagram
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.

SELECT c.* FROM unnest($parents::bigint[]) AS p(id) CROSS JOIN LATERAL ( SELECT * FROM comments WHERE post_id = $post AND parent_id = p.id AND state <> 2 ORDER BY s_best DESC LIMIT $k ) c;

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

StubWhen it appearsWhat the cursor holds
More repliesa node has more children than its level allowedparent id, sort, offset or last score seen
Continue this threadthe depth limit was reached but children existthe comment to use as a new root
More top-levelthe post has more root commentssort 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.

Deleted is not gone

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

07

Trade-offs

Chosen
Parent pointer plus materialised path

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 reshapes
Rejected
Closure table or nested sets

A 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 write
Chosen
Scores stored and indexed per sort

Every sort becomes an index scan. The price is extra columns and index maintenance on each score update, which batching keeps cheap.

Reads stay cheap
Rejected
Compute the sort at read time

No 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 threads
Chosen
Votes aggregated every few seconds

Each 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 seconds
Rejected
Increment the row on every vote

Exact and immediate, and it turns a popular comment into a lock hotspot that slows every other write to its shard.

Row contention
Chosen
Cache the first page, not individual comments

The 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 views
Alternative
Cache comments by id and assemble per request

Finer 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.

Complementary
Chosen
Notifications, search and moderation off the write path

The 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 writes
Cost
Spam is visible briefly

A 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 community

08

Failure modes

08

Failure modes

🔥
A megathread melts its shard

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.
🪜
A reply chain thousands of levels deep

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.
🗳️
Vote brigading and bots

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.
⏳
The aggregator falls behind

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.
👻
My comment vanished

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.
🧨
Deleting a parent breaks the tree

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

09

How to handle the interview

1

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".

2

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.

3

Give the fetch a budget. Concrete per-level limits and a total node cap show you have thought about what the page actually displays.

4

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.

5

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.

6

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.

7

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

11

Growing the design

Stage 1: a small forum
Load the whole thread

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.

Stage 2: threads in the thousands
Per-level fetches and stored scores

Add score columns per sort and an index on parent plus score, then switch to budgeted level-by-level fetching with "more" stubs.

Stage 3: millions of daily readers
Cached first pages and batched votes

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.

Stage 4: one database is not enough
Shard by post

Comments move to many shards keyed by post id, hot shards get replicas, and the votes store, which is keyed by user, scales independently.

Stage 5: live events
Push updates and hot-thread handling

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

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.