Build a Comment Threading System with Infinite Nesting
databases data-engineering performance
System Design Deep Dive
Infinite Nesting Comment System
Rendering comment trees that nest forever, without ever fetching more of the tree than the reader is about to see.
Picture a genealogist handed a family tree that has been growing for two hundred years, some branches with one child per generation, others where a single couple had eleven kids who each started sprawling families of their own. She does not print the entire tree just to answer “who are Great-Aunt Rosa’s descendants.” She opens the folder labeled Rosa, sees a count of how many descendants sit inside it without reading every name, and only opens the next folder down when she actually needs to see who is in it. She never touches a branch nobody asked about, and closing a folder back up does not throw away what is inside it, it just stops displaying it.
Now replace the genealogist with a comment rendering service, replace two centuries of family history with a single post that has accumulated 50,000 comments within 24 hours, and replace folders with reply threads that nest to arbitrary depth, an argument nested nine, twelve, sometimes past three hundred replies deep, because nothing in the product stops a reply to a reply to a reply. The service has to render a full comment tree at any depth fast enough that a reader never notices, let anyone collapse a subtree they are not interested in without discarding what was already fetched, and page through a thread with tens of thousands of top-level comments without ever loading more than what fits on one screen. It has to do this at a p99 read latency under 200ms for a thread that might have 50,000 rows, or might have twelve.
The naive approach, model each comment as a row with a parent_id and recursively walk it with a SQL recursive CTE every time someone opens a thread, fails for a specific and boring reason: a recursive CTE that has to walk depth by depth degrades linearly with depth, and postgres cannot parallelize a WITH RECURSIVE query across depth levels the way it can parallelize a table scan. A thread where one subthread genuinely reaches depth 300 turns “render this page” into three hundred sequential joins, and it happens often enough on any product that allows real conversation that “just recurse on read” produces latency spikes nobody can predict from the thread’s total comment count alone. The opposite naive approach, flatten everything into a single denormalized JSON blob per post and ship the whole tree to the client on page load, avoids the recursive-query problem entirely but throws away the actual requirement: a 50,000-comment thread’s blob is tens of megabytes, and shipping it whole to render twenty visible comments is the read-path equivalent of printing the entire family tree to answer one question about Great-Aunt Rosa.
We need to solve for three things simultaneously: a storage structure that supports constant insertion at unpredictable, unbounded depth without the write cost exploding as the tree grows, a read path that fetches only the branch of the tree a user is actually looking at and pages through the rest on demand, and per-node bookkeeping, reply counts, collapse state, sort order, that stays close to accurate without becoming a bottleneck of its own at thousands of writes per second.
Requirements and Constraints
Functional Requirements
- Let a user reply to any comment at any depth; the data model imposes no artificial nesting limit, though the render layer may cap visible depth
- Fetch and render a full comment tree for a given post efficiently, without an unbounded number of round trips or an unbounded single query
- Paginate top-level comments on a thread, sorted by a selectable order (
best,newest,controversial), in pages of a fixed size - Lazily load replies under any given comment, in pages, without ever fetching that comment’s entire subtree up front
- Let a client collapse a subtree, locally or via a persisted preference, without discarding data already fetched and without re-fetching it on expand if it is still cached
- Maintain and display both a direct reply count and a total descendant count per comment, updated close to real time
- Support deleting a comment while its replies remain visible and addressable, since a reply’s parent can be removed without invalidating the reply itself
- Sort each parent’s children independently; a thread sorted
bestat the top level does not force every nested subthread to also be sortedbest
Non-Functional Requirements
- Scale: a single viral post can accumulate 50,000 comments within 24 hours; nesting depth in a genuinely heated subthread routinely exceeds 100 levels and has been observed past 300
- Write throughput: 5,000 new comments/second platform-wide at peak, with a single hot thread absorbing up to 400 comments/second during a live event
- Read latency: the thread-page read path targets a
p99under 200ms regardless of whether the underlying thread has twelve comments or fifty thousand - Write latency: a comment submission acknowledges to the author within 150ms; the comment does not need to be visible to every other reader instantaneously, but it must be visible to the author who posted it immediately
- Freshness: reply counts may lag the true count by a few seconds under load; a user’s own collapse or dismiss action must apply instantly and must never be reverted by a later background process
- Availability: the read path targets 99.95% uptime; the write path must never be blocked or slowed by the read-heavy traffic a popular thread generates
- Depth safety: no single comment insertion may cost more than a bounded, constant multiple of that comment’s depth, and no single thread’s growth may degrade write latency for comments on unrelated threads
Every one of these constraints points at the same underlying decision: the storage structure has to make "give me this one comment's children" and "give me this one comment's entire subtree" both cheap, indexed operations, because the read path is going to ask for exactly those two things, over and over, and almost never anything else.
Constraints and Assumptions
- Moderation, spam detection, and profanity filtering are treated as an upstream signal this system consumes through a
visibilityflag on each comment, not something it computes - Real-time delivery of new comments to an already-open thread (via websocket push) is out of scope; this design covers persistence, tree assembly, and the request/response read path
- We assume comments are stored in a relational database (Postgres-compatible) rather than a purpose-built graph database, because the access patterns, indexed lookups by parent and by ancestor, do not require general graph traversal
- Re-parenting a comment, a moderator merging two duplicate subthreads into one, is addressed only as a discussion point in the comparison section, not as a fully built feature
- Rich text rendering, mentions, and attachments are out of scope; a comment body is treated as an opaque text blob for the purposes of this design
High-Level Architecture
The system has six major components: the Comment Write Path, the Adjacency List and Closure Table Storage Layer, the Lazy Subtree Loader and Depth-Limited Tree Assembly read path, the Per-Level Sort and Rank Engine, the Reply Count Denormalization Pipeline, and the Collapse State and Auto-Collapse Engine.
The Comment Write Path is the front door for every new comment: it validates the body, resolves the parent and root post, computes the new comment’s depth, and hands off a single atomic insert to storage. The Adjacency List and Closure Table Storage Layer is the structural core: every comment row carries a parent_id for cheap “who are the direct children” queries, and a companion closure table carries one row per ancestor-descendant pair so “give me this entire subtree” is an indexed range scan instead of a recursive walk. The Lazy Subtree Loader is what actually answers a read request: it fetches one bounded page of children at a time, caps how deep it will recurse in a single response, and returns a cursor for whatever it left out. The Per-Level Sort and Rank Engine decides the order children come back in, independently for every parent, based on a score computed once at write time and re-orderable at read time. The Reply Count Denormalization Pipeline keeps direct_reply_count and total_descendant_count current on every ancestor without making a comment’s insert transaction wait on updating every ancestor synchronously. The Collapse State and Auto-Collapse Engine decides which subtrees render collapsed by default and lets a client collapse or expand a subtree without discarding or re-fetching data it already has.
A comment’s life looks like this: a user submits a reply, the write path resolves its parent and depth, and a single transaction inserts the comment row plus one closure-table row per ancestor, then asynchronously bumps every ancestor’s reply counters. When any client requests a thread page, the lazy subtree loader fetches the top-level comments for that post from the adjacency table, sorted by whatever order the per-level sort engine has ranked them under, capped at a fixed page size and a fixed render depth; each returned comment carries its own total_descendant_count so the client can render “47 more replies” for a subtree it has not fetched yet, without ever having fetched it. Expanding a collapsed subtree, or clicking “load more replies,” issues a new bounded request scoped to exactly that one parent, never the whole thread.
The single most important architectural decision is that no read request is ever allowed to be proportional to the size of the whole thread. Every read is scoped to one parent's children, bounded by a page size and a render-depth cap, which is what lets a 50,000-comment viral thread and a twelve-comment thread hit the exact same `p99` latency target.
Component Deep Dives
The Comment Write Path
This component’s job is to turn a submitted reply into a validated, correctly-placed row in storage, and to acknowledge the author quickly without waiting on any expensive downstream work.
The mistake a simpler design makes is computing a comment’s full ancestor chain by walking parent_id pointers one at a time inside the write transaction. That works, but it means the transaction’s latency grows with the parent’s depth, and a reply to a comment 200 levels deep would take measurably longer to submit than a reply to a fresh top-level comment, for no reason the author can see or would tolerate. Instead, the write path fetches the parent’s existing ancestor list in a single indexed query against the closure table, computes the new depth as parent.depth + 1, and builds the full set of closure rows to insert in one batch, so submission latency is dominated by one query plus one insert, not by depth.
-- Fetch the parent's full ancestor chain in one indexed scan; this
-- is the only lookup the write path needs before it can compute the
-- new comment's depth and closure rows.
SELECT ancestor_id, depth
FROM comment_closure
WHERE descendant_id = $1 -- parent_id of the new comment
ORDER BY depth;
# comment_write_path.py - validates, computes depth, and builds the
# full insert batch (comment row + closure rows) in one pass, so a
# reply's write latency does not grow with how deep its parent sits
from dataclasses import dataclass
MAX_DEPTH = 500 # hard ceiling; deep enough to never matter to a real user
@dataclass
class NewComment:
comment_id: int
root_post_id: int
parent_id: int | None
author_id: int
body: str
def prepare_comment_insert(new_comment: NewComment, parent_ancestors: list[tuple[int, int]]):
if new_comment.parent_id is None:
depth = 0
closure_rows = [(new_comment.comment_id, new_comment.comment_id, 0)]
return depth, closure_rows
depth = max((d for _, d in parent_ancestors), default=-1) + 2
if depth > MAX_DEPTH:
raise ValueError(f"reply exceeds max nesting depth of {MAX_DEPTH}")
closure_rows = [
(ancestor_id, new_comment.comment_id, ancestor_depth + 1)
for ancestor_id, ancestor_depth in parent_ancestors
]
closure_rows.append((new_comment.parent_id, new_comment.comment_id, 1))
closure_rows.append((new_comment.comment_id, new_comment.comment_id, 0))
return depth, closure_rowsPremium Content
Unlock the full article along with everything else in the archive — all in one place.