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.

⏱ 17 min read📐 Advanced🏗️ Databases

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 best at the top level does not force every nested subthread to also be sorted best

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 p99 under 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
Key Insight

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 visibility flag 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.

Architecture overview showing a comment write service inserting adjacency and closure table rows and incrementing ancestor counters, a lazy subtree loader reading from storage through a per-level sort engine and a render fragment cache, and a client thread UI issuing both write and paginated read requests

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.

Key Insight

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_rows

Think of this like a mailroom clerk who, instead of walking the entire building floor by floor to figure out which mail slots a new memo needs to reach, keeps a standing distribution list per department and just copies it. What breaks without this: a write path that walks parent_id one hop at a time to build the ancestor chain turns every reply’s insert cost into O(depth) sequential round trips instead of one query, and at a hot thread doing 400 writes/second with subthreads 100 levels deep, that difference is the gap between a write path that stays under 150ms and one that does not.

Watch Out

A common mistake is enforcing the MAX_DEPTH ceiling only in the client UI and not in the write path itself. A scripted or malicious client can submit replies directly against the API, and without a server-side check, a deliberately deep reply chain can be used to make individual comment inserts arbitrarily expensive, since every insert still has to write one closure row per ancestor.

The Adjacency List and Closure Table Storage Layer

This component’s job is to store the tree in a shape that makes both “who are this comment’s direct children” and “what is this comment’s entire subtree” cheap, indexed queries, without either one degrading as the tree gets deeper.

The non-obvious tradeoff here is that no single tree representation is good at everything, and picking one requires being honest about which two queries actually dominate. An adjacency list, a plain parent_id column, makes “direct children” trivially cheap and inserts are O(1), but “give me this comment’s entire subtree” requires a recursive walk. A nested set model, the classic lft/rgt interval encoding, makes subtree reads a single range query, but every insert has to shift the lft/rgt values of every node to the right of the insertion point, an O(n) write against the whole tree, which is unusable for a structure that takes thousands of concurrent inserts a second. We use both an adjacency list and a closure table together: the adjacency list stays the source of truth for parent-child relationships and drives the cheap direct-children query, and the closure table stores one row per ancestor-descendant pair at every depth, so a subtree fetch or an ancestor-chain fetch is a single indexed scan against comment_closure, not a recursive walk against comments.

-- comments: the adjacency list, source of truth for parent-child
-- relationships and per-level metadata
CREATE TABLE comments (
    comment_id             BIGINT      PRIMARY KEY,
    root_post_id           BIGINT      NOT NULL,
    parent_id              BIGINT      REFERENCES comments(comment_id),
    author_id              BIGINT      NOT NULL,
    body                    TEXT        NOT NULL,
    depth                   SMALLINT    NOT NULL DEFAULT 0 CHECK (depth >= 0),
    sort_score              DOUBLE PRECISION NOT NULL DEFAULT 0,
    direct_reply_count      INT         NOT NULL DEFAULT 0,
    total_descendant_count  INT         NOT NULL DEFAULT 0,
    created_at              TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    deleted_at              TIMESTAMPTZ,
    visibility              TEXT        NOT NULL DEFAULT 'visible'
        CHECK (visibility IN ('visible', 'deleted', 'held_for_review'))
);

CREATE INDEX idx_comments_children
    ON comments (root_post_id, parent_id, sort_score DESC);

CREATE INDEX idx_comments_top_level
    ON comments (root_post_id, sort_score DESC)
    WHERE parent_id IS NULL;

-- comment_closure: one row per ancestor-descendant pair, including a
-- comment's zero-depth self-row; this is what makes subtree and
-- ancestor-chain reads a single indexed scan instead of a recursive walk
CREATE TABLE comment_closure (
    ancestor_id     BIGINT   NOT NULL REFERENCES comments(comment_id),
    descendant_id   BIGINT   NOT NULL REFERENCES comments(comment_id),
    depth           SMALLINT NOT NULL,
    PRIMARY KEY (ancestor_id, descendant_id)
);

CREATE INDEX idx_closure_ancestor ON comment_closure (ancestor_id, depth);
CREATE INDEX idx_closure_descendant ON comment_closure (descendant_id, depth);
Comparison of the adjacency list and closure table structures for a small five-comment tree, showing the adjacency list's parent_id column beside the closure table's expanded ancestor-descendant-depth rows

Think of the closure table like a library that keeps not just a shelf list of which book sits directly next to which, but a separate cross-reference index recording every book’s full lineage back to the collection’s founding donation, so answering “everything traceable to this one donor” never means walking the shelves one by one. What breaks without it: fetching a 300-level-deep subtree with only an adjacency list means issuing a recursive CTE that walks one level per iteration, and postgres cannot parallelize that walk, so the query’s latency is bounded below by the subtree’s depth, not by how much hardware you throw at it.

Real World

Reddit’s original comment storage used a similar dual structure in spirit: a fast path for direct parent-child lookups and a separate mechanism for reconstructing full comment trees, precisely because a pure adjacency-list recursive walk does not scale to threads with tens of thousands of comments and deeply nested subthreads.

The Lazy Subtree Loader and Depth-Limited Tree Assembly

This component’s job is to answer “render this thread” and “load more replies under this comment” without ever fetching more of the tree than what is about to be shown on screen.

The instinct a less experienced design falls into is treating “load the thread” as one big recursive fetch that returns the whole tree in one response, trusting the client to only render what fits. That inverts the actual cost: the expensive part was never rendering, it is the database work and the network payload of fetching data nobody scrolls to. Instead, every fetch is bounded two ways at once, a page size limiting how many siblings come back per request, and a depth cap limiting how many levels a single response recurses before handing back a pointer instead of more data.

# lazy_subtree_loader.py - fetches one bounded page of a comment
# tree per call; recursion stops at max_depth and returns a
# "continue_thread" pointer instead of fetching further
from dataclasses import dataclass, field

PAGE_SIZE = 10
MAX_RENDER_DEPTH = 6  # UI collapses past this; deeper replies load on demand

@dataclass
class CommentNode:
    comment_id: int
    author_id: int
    body: str
    total_descendant_count: int
    children: list["CommentNode"] = field(default_factory=list)
    next_cursor: str | None = None
    continue_thread: bool = False

def fetch_children_page(fetch_fn, parent_id: int, root_post_id: int,
                         cursor: tuple[float, int] | None, limit: int = PAGE_SIZE):
    # fetch_fn issues the keyset-paginated SQL query below and
    # returns (rows, next_cursor)
    return fetch_fn(parent_id, root_post_id, cursor, limit)

def build_render_tree(fetch_fn, comment_id: int, root_post_id: int, current_depth: int = 0):
    if current_depth >= MAX_RENDER_DEPTH:
        return CommentNode(
            comment_id=comment_id, author_id=0, body="",
            total_descendant_count=0, continue_thread=True,
        )
    rows, next_cursor = fetch_children_page(fetch_fn, comment_id, root_post_id, cursor=None)
    children = [
        build_render_tree(fetch_fn, row.comment_id, root_post_id, current_depth + 1)
        for row in rows
    ]
    return CommentNode(
        comment_id=comment_id, author_id=0, body="",
        total_descendant_count=sum(1 for _ in children),
        children=children, next_cursor=next_cursor,
    )
-- Keyset-paginated fetch of one parent's children, sorted by
-- whatever sort_score the per-level sort engine assigned. Keyset
-- pagination (WHERE (sort_score, comment_id) < cursor) is used
-- instead of OFFSET because OFFSET degrades linearly with page
-- number on a parent with thousands of direct replies.
SELECT comment_id, author_id, body, sort_score,
       direct_reply_count, total_descendant_count, created_at
FROM comments
WHERE parent_id = $1
  AND root_post_id = $2
  AND visibility = 'visible'
  AND (sort_score, comment_id) < ($3, $4)
ORDER BY sort_score DESC, comment_id DESC
LIMIT 10;
Lazy subtree loading diagram showing a rendered tree stopping at a depth cap with a continue-thread pointer, and a load-more-replies request fetching the next keyset-paginated page of a single parent's children

This is the same trick a streaming video service uses for a long video: it does not download the whole file before letting you watch the first ten seconds, it fetches a bounded chunk and fetches the next chunk only once you keep watching. What breaks without the depth cap specifically: a subthread that happens to be 300 levels deep would recurse 300 times inside a single response, and even bounding sibling count per level does not bound total response size when depth itself is unbounded, since the response payload grows linearly with depth regardless of how narrow each level is.

Watch Out

A common mistake is using OFFSET-based pagination for a parent with a large number of direct replies, since it reads naturally as “page 2, page 3” of a comment section. OFFSET 5000 still requires postgres to scan and discard the first five thousand matching rows before returning the next page, and on a hot thread’s most-replied-to comment, that cost compounds with every page a reader clicks through.

The Per-Level Sort and Rank Engine

This component’s job is to decide the order in which a parent’s children are returned, independently for every parent in the tree, based on a score that does not require re-sorting the whole thread when one comment gets a new upvote.

The non-obvious part is what “per-level” actually buys you: it is not just an implementation detail, it is a product requirement. A reader viewing a top-level comment sorted best expects the replies nested under it to also default to best, not newest, and a different reader who switches the whole thread to newest expects every level to follow, without the server needing a special case for each combination. Because sort_score lives on the comment row itself and every read query’s ORDER BY is scoped to a single parent_id, sort order composes naturally: each level is sorted independently by the same rule, and no level’s ordering depends on any other level’s.

# comment_sort_score.py - a decaying hot-score, in the spirit of
# Reddit's ranking algorithm, computed once at write time and
# recomputed on a vote; higher is "better" for the `best` sort order
import math

EPOCH = 1_700_000_000  # platform launch epoch, keeps early scores small

def comment_hot_score(upvotes: int, downvotes: int, created_at: float) -> float:
    net_score = upvotes - downvotes
    magnitude = math.log10(max(abs(net_score), 1))
    sign = 1 if net_score > 0 else (-1 if net_score < 0 else 0)
    seconds_since_epoch = created_at - EPOCH
    return round(sign * magnitude + seconds_since_epoch / 45_000, 7)

def comment_controversy_score(upvotes: int, downvotes: int) -> float:
    if upvotes == 0 or downvotes == 0:
        return 0.0
    total = upvotes + downvotes
    balance = min(upvotes, downvotes) / max(upvotes, downvotes)
    return (total ** balance)
Key Insight

The property that makes per-level sorting cheap is that sort_score is a single stored column, not a rank computed relative to siblings at read time. Storing an absolute, comparable score means the read query is a plain indexed ORDER BY, and switching between best, newest, and controversial is just switching which column the same query orders by, sort_score, created_at, or controversy_score, never a different code path per sort mode.

Think of it like a library where every book on every shelf has its own Dewey decimal number computed independently, so re-sorting one shelf never requires touching the numbers on any other shelf. What breaks without a per-comment stored score: a design that computes rank relative to siblings, “this is reply number 3 out of 40,” would need to recompute every sibling’s rank whenever one comment’s vote count changes, turning a single vote into a write against every other reply at that level.

The Reply Count Denormalization Pipeline

This component’s job is to keep direct_reply_count and total_descendant_count current on every ancestor of a new comment, without making the comment’s own insert transaction wait on writing to every one of those ancestors synchronously.

The mistake a naive design makes is updating every ancestor’s counters inside the same transaction that inserts the comment. On a shallow reply that costs one extra update. On a reply 100 levels deep, it costs 100 synchronous row updates in the critical path of a single comment submission, and on a hot thread where many comments share long ancestor chains, those updates start lock-contending with each other, since two concurrent replies under the same great-grandparent are both trying to increment the same row. We decouple the acknowledgment from the counter update: the insert transaction commits as soon as the comment and its closure rows are written, and counter increments happen asynchronously against an in-memory buffer that gets flushed to postgres in small batches.

# increment_counts.py - called immediately after a comment insert
# commits; buffers ancestor counter increments in Redis instead of
# blocking the insert transaction on N ancestor row updates
def increment_ancestor_counts(redis_client, ancestor_ids: list[int], direct_parent_id: int | None):
    pipe = redis_client.pipeline()
    for ancestor_id in ancestor_ids:
        pipe.hincrby(f"reply_count:{ancestor_id}", "total_descendant_count", 1)
        pipe.sadd("dirty_reply_counters", ancestor_id)
    if direct_parent_id is not None:
        pipe.hincrby(f"reply_count:{direct_parent_id}", "direct_reply_count", 1)
    pipe.execute()
// flush_counters.go - periodically drains buffered counter deltas
// from Redis into postgres in batches, keeping comments.total_descendant_count
// close to real time without every insert paying for a synchronous DB write
func flushDirtyCounters(ctx context.Context, rdb *redis.Client, db *sql.DB) error {
	ids, err := rdb.SMembers(ctx, "dirty_reply_counters").Result()
	if err != nil {
		return err
	}
	for _, idStr := range ids {
		key := "reply_count:" + idStr
		counts, err := rdb.HGetAll(ctx, key).Result()
		if err != nil || len(counts) == 0 {
			continue
		}
		direct, _ := strconv.Atoi(counts["direct_reply_count"])
		total, _ := strconv.Atoi(counts["total_descendant_count"])
		_, err = db.ExecContext(ctx, `
			UPDATE comments
			SET direct_reply_count = direct_reply_count + $1,
			    total_descendant_count = total_descendant_count + $2
			WHERE comment_id = $3`, direct, total, idStr)
		if err != nil {
			continue
		}
		rdb.HDel(ctx, key, "direct_reply_count", "total_descendant_count")
		rdb.SRem(ctx, "dirty_reply_counters", idStr)
	}
	return nil
}

Think of this like a store that does not reconcile its cash register against the books after every single sale, it lets the register run and reconciles totals at the end of each shift, because stopping to update the ledger after every transaction would make the line move too slowly for customers actually waiting to check out. What breaks without this decoupling: a comment nested 200 levels deep would require 200 synchronous counter updates before the author’s submission even acknowledges, directly violating the 150ms write latency target, and would create lock contention on shared ancestor rows during exactly the traffic spikes, viral threads, where write throughput matters most.

Real World

Instagram’s like-count and comment-count systems are famously eventually consistent for the same reason: counters that update synchronously on the hot write path do not survive contact with a viral post’s write volume, and a short-lived, bounded lag between “the count changed” and “the count is visible everywhere” is a far better tradeoff than slowing down every write to keep counts perfectly synchronous.

The Collapse State and Auto-Collapse Engine

This component’s job is to decide which subtrees a reader sees collapsed by default, and to make expanding or collapsing a subtree a purely local, instant operation that never re-fetches data the client already has.

The subtlety here is that “collapsing a subtree without loading the entire thread” is not a UI toggle on top of an otherwise-eager fetch, it has to be true structurally: a comment the reader has never expanded should never have had its replies fetched in the first place. The lazy subtree loader already guarantees this on the way in, since a comment’s total_descendant_count is returned without its children ever being requested. Collapse state just needs to track, per comment and per viewer, whether a subtree that could be shown is currently hidden, and that is cheap because it is a client-local boolean, or at most a small per-user override table, never a re-fetch.

# auto_collapse.py - decides default collapse state for a subtree the
# client has not fetched yet, using only data already returned with
# the parent comment: no extra round trip is needed to make this call
def should_auto_collapse(comment, viewer_settings) -> bool:
    if comment.deleted:
        return True  # deleted comments collapse by default; replies stay visible
    if comment.sort_score < viewer_settings.collapse_threshold and comment.upvotes + comment.downvotes >= 5:
        return True  # enough votes to trust the signal, and it's a bad one
    if comment.total_descendant_count > 200 and comment.depth > 2:
        return True  # a sprawling subthread nested deep is opt-in reading, not default
    return False
-- Persisted per-user collapse overrides: only written when a viewer
-- explicitly collapses or re-expands a comment the auto-collapse
-- logic did not already decide correctly for them
CREATE TABLE comment_collapse_overrides (
    user_id     BIGINT      NOT NULL,
    comment_id  BIGINT      NOT NULL REFERENCES comments(comment_id),
    collapsed   BOOLEAN     NOT NULL,
    updated_at  TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    PRIMARY KEY (user_id, comment_id)
);
Diagram showing two parent comments with independently sorted children, one by best score and one by newest, alongside a collapse state example where a high descendant-count subtree is auto-collapsed using only data already returned with the parent comment

Think of it like a filing cabinet where every folder tab already shows how many pages are inside without you opening it, so deciding whether a folder is worth opening never requires opening it first. What breaks without decoupling collapse from fetch: a design that fetches a subtree eagerly and only hides it in the DOM technically satisfies “the user sees it collapsed,” but it has already paid the database and network cost of a full fetch, which defeats the entire purpose of collapsing a 50,000-comment thread’s less relevant branches.

Watch Out

A common mistake is making auto-collapse decisions based on a live vote tally fetched at render time instead of the sort_score already returned with the comment. That turns a decision that should cost nothing extra into a second query per comment being considered for collapse, which, multiplied across a page of fifty top-level comments, adds real latency to every single thread-page load just to decide what to hide.

Data Model

The data model has four core entities: comments, comment_closure, comment_collapse_overrides, and a lightweight posts table each comment’s root_post_id references.

-- posts: minimal fields the comment system actually needs from the
-- owning post; the full post entity lives in a separate service
CREATE TABLE posts (
    post_id       BIGINT      PRIMARY KEY,
    author_id     BIGINT      NOT NULL,
    created_at    TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    comment_count INT         NOT NULL DEFAULT 0
);

comments and comment_closure, shown in full in the storage layer section above, are the two tables every read and write in this system touches. comments is partitioned by root_post_id: since virtually every query, direct children, top-level page, ancestor chain lookup, is scoped to a single post’s thread, keeping one thread’s rows co-located on one partition means a read never has to fan out across shards to answer a single-thread question. comment_closure is partitioned the same way, by the root_post_id of its rows’ comments, so a subtree fetch stays local to the same partition as the comments it returns.

Data flow diagram showing a comment's lifecycle from submission through adjacency and closure row inserts, asynchronous ancestor counter increments, render fragment cache invalidation, and the feedback loops for soft-deletion and counter reconciliation

A comment’s life starts the moment it is submitted: the write path validates it, computes its depth from its parent’s ancestor chain, and a single transaction inserts one comments row and one comment_closure row per ancestor. From there, two things happen asynchronously and independently: ancestor reply counters are buffered in Redis and flushed to postgres in small batches, and the render fragment cache entry for the comment’s direct parent is invalidated, so the next reader to load that parent’s children sees the new reply. If the comment is later deleted, it is soft-deleted, visibility flips to deleted and the body is replaced, but its comments row and every comment_closure row referencing it stay intact, since its own children still need a valid ancestor chain to resolve their depth and their ancestor-scoped queries.

Key Insight

Deletion is deliberately not a hard delete. If a comment with a hundred replies were physically removed, every one of those replies would lose an ancestor row it depends on for correct depth computation and ancestor-chain queries. Soft-deleting and rendering [deleted] in its place keeps the tree structurally intact while still honoring the intent to remove the content.

Key Algorithms and Protocols

Closure Table Maintenance on Insert

Covered in the Comment Write Path section above, inserting a new comment writes exactly ancestor_count + 1 closure rows, one per true ancestor plus the comment’s own zero-depth self-row, an O(depth) operation, not the O(n) whole-tree rewrite a nested set model would require for the same insert.

-- Equivalent single-statement form of the closure insert, useful
-- when the parent's ancestor chain is fetched in the same query
-- rather than passed in from application code
INSERT INTO comment_closure (ancestor_id, descendant_id, depth)
SELECT ancestor_id, $1, depth + 1
FROM comment_closure
WHERE descendant_id = $2  -- parent_id of the new comment
UNION ALL
SELECT $1, $1, 0;
Key Insight

The property that makes this safe at unbounded depth is that write cost scales with the depth of the specific comment being inserted, not with the size of the tree it belongs to. A reply to a fresh top-level comment on a 50,000-comment thread costs the same one closure row as a reply on an empty thread; only that one comment’s own ancestor chain matters.

Subtree and Ancestor-Chain Retrieval

Fetching an entire bounded subtree, used when a client expands “show all replies” rather than paging incrementally, is a single indexed range scan against comment_closure.

-- Full subtree under a given comment, capped at a maximum depth
-- below it, ordered by depth first so a client can render top-down
-- without needing to reconstruct order from parent pointers
SELECT cc.descendant_id, cc.depth, c.parent_id, c.author_id, c.body, c.sort_score
FROM comment_closure cc
JOIN comments c ON c.comment_id = cc.descendant_id
WHERE cc.ancestor_id = $1
  AND cc.depth BETWEEN 1 AND $2  -- exclude the ancestor's own self-row at depth 0
  AND c.visibility = 'visible'
ORDER BY cc.depth, c.sort_score DESC;

This runs in O(subtree size) time bounded by the requested depth cap, using the idx_closure_ancestor index, versus O(subtree size * average depth) for an equivalent recursive CTE walk against the adjacency list alone, since the recursive form re-joins at every level instead of reading pre-expanded rows.

Depth-Limited Rendering

Covered in the Lazy Subtree Loader section above, build_render_tree bounds both breadth, via PAGE_SIZE, and depth, via MAX_RENDER_DEPTH, so a single tree-assembly response is bounded in total size by PAGE_SIZE ^ MAX_RENDER_DEPTH in the worst case, though in practice actual reply counts per level keep responses far smaller than that theoretical ceiling.

Key Insight

The property that makes depth-limited rendering correct rather than lossy is that continue_thread: true is a pointer, not a truncation. Nothing is discarded, the exact comment ID needed to resume the walk is returned alongside the flag, so “continue this thread” is a normal lazy-load request against a real, addressable node, not a dead end.

Keyset-Paginated Thread Listing

Covered in the Lazy Subtree Loader section above, keyset pagination over (sort_score, comment_id) runs in O(page size) time per page regardless of how many pages have already been fetched, unlike OFFSET-based pagination which runs in O(offset + page size) because postgres has to scan and discard every preceding row.

# Edge case handling for keyset pagination: ties in sort_score
# (e.g. two brand-new comments scored at the same second) must be
# broken by a strictly monotonic tiebreaker, comment_id, or the
# cursor comparison can skip or repeat rows across pages
def encode_cursor(last_row) -> tuple[float, int]:
    return (last_row.sort_score, last_row.comment_id)

Scaling and Performance

The storage layer is partitioned by root_post_id, so a single thread’s comments and comment_closure rows live on one partition, and the overwhelming majority of queries never fan out across shards. The one case that breaks this assumption cleanly is the viral thread: a single root_post_id receiving 400 writes/second and accumulating 50,000 comments in a day can outgrow what one partition comfortably serves, both for writes and for the read replicas backing it.

Scaling diagram showing threads hash-partitioned by root_post_id across shards, with a viral thread's closure table rows further sub-sharded by top-level ancestor once the thread crosses a size threshold

Once a thread crosses a size threshold, its closure table rows are sub-sharded by top-level ancestor, every comment nested under a given top-level comment is routed to the same sub-shard, since subtree and ancestor-chain queries almost never cross a top-level boundary. This mirrors how a hot key gets split anywhere else in a partitioned system: instead of one partition absorbing all of a viral thread’s load, the thread’s own top-level branches become the new partitioning boundary.

Capacity Estimation:

Given:
  - 5,000 new comments/second platform-wide at peak, ~150M
    comments/day
  - Average nesting depth of 8, with rare subthreads exceeding 300
  - A single viral thread can reach 50,000 comments within 24 hours
  - Average comment body: 280 bytes; fixed row overhead: ~120 bytes
  - Read path: 40,000 thread-page loads/second at peak, ~3 subtree
    fragment fetches per page load

Comments table storage:
  150,000,000 comments/day * (280 + 120) bytes/row
    = ~60 GB/day of comment rows before index overhead

Closure table storage (the dominant cost):
  Average depth 8 means each comment writes ~8 closure rows
  (itself plus 7 ancestors); a comment 300 levels deep writes 300
  150,000,000 comments/day * 8 avg closure rows * 24 bytes/row
    = ~28.8 GB/day for closure rows at typical depth

Write throughput:
  5,000 comments/sec * 8 avg closure rows
    = 40,000 closure row inserts/sec sustained, batched into one
      multi-row INSERT per comment rather than 8 round trips

Read fan-out:
  40,000 page loads/sec * 3 fragment fetches
    = 120,000 subtree queries/sec; at a 92% cache hit rate on the
      render fragment cache, ~9,600 queries/sec actually reach
      postgres, comfortably inside a read-replica fleet sized for
      15,000 qps/replica

Reply counter writes:
  5,000 comments/sec * 8 avg ancestors
    = 40,000 Redis HINCRBY ops/sec, flushed to postgres in batches
      every 2 seconds rather than per write

The render fragment cache, keyed by (parent_id, sort_mode, page_cursor), absorbs the overwhelming majority of read traffic, since a thread’s top few pages under its most popular sort order are requested far more often than any deep, rarely-visited branch. The dominant bottleneck is not aggregate read volume, it is keeping a single viral thread’s write path and its own read replicas from starving unrelated threads sharing the same partition before the sub-sharding threshold kicks in.

Real World

Discourse’s forum software handles deeply nested threads with a comparable structural bet: it keeps a flat post table with a reply_to_post_number pointer for fast direct-reply lookups, and materializes a separate summary structure for full-topic rendering, specifically so a topic with tens of thousands of posts does not force every page load to walk the raw reply graph.

Failure Modes and Recovery

FailureDetectionImpactRecovery
Pathologically deep reply chain (over 300 levels) on a single threadDepth-distribution alert on comments.depth; write-path latency spike on that threadA single comment insert writes hundreds of closure rows, slowing the write path for that one threadServer-side MAX_DEPTH ceiling rejects deeper replies with a clear error; depth-limited rendering flattens display well before the cap is ever reached
Hot thread write contention (400 writes/sec to one root_post_id partition)Per-partition write latency and lock-wait metricsComment inserts on that one thread queue up; elevated p99 latency scoped to that thread onlySub-shard the thread’s closure rows by top-level ancestor once it crosses a size threshold, as described in scaling
Reply counter drift between Redis and postgres (Redis node restarts before a flush cycle completes)Reconciliation job comparing COUNT(*) from comment_closure against stored total_descendant_count, alerting past a toleranceDisplayed reply counts run slightly under the true countNightly reconciliation job recomputes counts from comment_closure, the ground truth, and corrects postgres; Redis is a write buffer, never the source of truth
Parent comment deleted while replies still existNone needed, this is an expected, routine caseA naive hard delete would orphan or cascade-delete legitimate repliesSoft-delete tombstone (visibility = 'deleted', body replaced); comment_closure rows referencing it are retained so descendants keep a valid ancestor chain
Cache stampede on a newly viral thread’s top-level pageSpike in concurrent identical cache-miss queries against the same (parent_id, sort_mode) keyRead replica overload; elevated latency spills over onto unrelated threads sharing the replicaSingle-flight lock on a cache-miss key, so only one request populates the cache while concurrent requests wait on that in-flight fetch instead of each issuing their own query
Network partition isolates a shard holding a subset of threads from the write pathWrite timeout and partition-unreachable error rate spike from that shardWrites to threads on the isolated shard fail or queue client-sideClient-side retry with an idempotency key on the comment submission, so a retried write cannot create a duplicate comment; reads fall back to a slightly stale cached fragment until the shard recovers
Watch Out

The most common operational mistake is treating the reply-counter reconciliation job as an optional cleanup task instead of a required part of the system. Redis counter drift compounds silently, a lost increment here, a double-counted flush there, and without a ground-truth reconciliation pass running regularly against comment_closure, a thread’s displayed reply count can drift meaningfully out of sync with reality over weeks without any single event that would trigger an alert.

Comparison of Approaches

ApproachLatencyComplexityFailure modeBest fit
Adjacency list only, recursive CTE on readWrites are O(1) and fast; subtree reads degrade linearly with depth since postgres cannot parallelize a recursive walkLow, a single table and one recursive queryA subthread nested deep enough turns a “render this branch” query into a slow, sequential walk that can time outProducts with shallow, bounded nesting, or ones that only ever need direct children, never a full subtree in one query
Nested set model (lft/rgt intervals)Subtree reads are a single fast range query; every insert requires an O(n) shift of lft/rgt values across the treeHigh, insert logic is intricate and easy to get subtly wrong under concurrencyConstant high-volume inserts, which is exactly what a live comment thread produces, make the O(n) write cost unworkableRarely-changing hierarchies like category taxonomies or org charts, not live, high-write comment threads
Closure table plus adjacency list (this design’s default)Writes are O(depth), one row per ancestor; subtree and ancestor-chain reads are single indexed scansMedium, an extra table and real storage overhead that grows with depthStorage grows faster than linear in pathologically deep threads, and re-parenting a subtree is expensive since every affected closure row must be rewrittenComment threads and similar structures with frequent inserts, unpredictable depth, and frequent whole-subtree reads
Materialized path (ltree or a delimited path string like 1.4.22.108)Writes are O(1), only the new row’s own path is set; subtree reads use a fast prefix matchMedium, path strings grow with depth and carry a practical length ceilingRe-parenting requires rewriting the path of every descendant; extremely deep threads produce long paths that stress index size and comparison costTrees with bounded, reasonably shallow depth, like file systems or org charts, less ideal at genuinely unbounded depth

We would pick the closure table paired with an adjacency list for this design, because the write pattern is insert-heavy and depth is explicitly unbounded and unpredictable, which is exactly the combination the nested set model handles worst and the closure table handles best. The extra storage a closure table costs is a predictable, bounded tradeoff; the write-amplification a nested set model would cost under this write volume is not.

Key Takeaways

  • A closure table turns subtree and ancestor-chain reads into single indexed scans, trading write-time storage for read-time speed, which matters most when subtree reads happen far more often than deep re-parenting operations do.
  • Depth-limited rendering has to bound both breadth and depth independently. Bounding only how many siblings return per page still leaves response size unbounded on a thread with a genuinely deep, narrow subthread.
  • Keyset pagination, not OFFSET, is what keeps “load more replies” flat in cost regardless of how many pages a reader has already clicked through.
  • Per-level sort order composes for free once sort_score is a stored column read with a plain indexed ORDER BY. No special-casing is needed to make nested replies inherit their parent thread’s sort mode.
  • Reply count denormalization has to be decoupled from the write transaction, not just cached. A synchronous update to every ancestor on every insert turns a comment nested 200 levels deep into 200 sequential writes in the submission’s critical path.
  • Deletion in a tree structure has to be soft, never structural. A comment’s own children depend on its closure rows staying intact even after its content is gone.
  • Collapsing a subtree only saves real cost if the underlying data was never fetched in the first place. A UI-only collapse on top of an eager fetch defeats the entire purpose of lazy loading.
  • A single viral thread breaks the assumption that one partition can hold one thread. Sub-sharding by top-level ancestor once a thread crosses a size threshold is the same fix a hot key gets anywhere else in a partitioned system.

The counter-intuitive lesson is that the hardest-sounding part of this system, supporting genuinely infinite nesting, is made tractable by the same trick that shows up everywhere in distributed systems: never do work proportional to something unbounded. The tree itself can grow to any depth and any width; what has to stay bounded is the cost of any single operation against it, one insert, one page of children, one subtree fetch. Get that one invariant right and the fact that a subthread might be twelve levels deep or three hundred stops being a design concern at all, it just becomes a number the system was already built to not care about.

Frequently Asked Questions

Q: Why not just use a recursive CTE against the adjacency list on every read, instead of maintaining a whole separate closure table?

A: A recursive CTE’s cost is bound below by the depth of the subtree being walked, since postgres processes it one level at a time and cannot parallelize across levels. For a thread with subthreads exceeding a hundred levels, that turns a single page render into a slow, sequential query. The closure table pre-expands every ancestor-descendant relationship at write time, so the same read becomes a single indexed range scan instead of a recursive walk, at the cost of extra storage and slightly more expensive writes.

Q: Why not use the nested set model, since subtree range queries would be even simpler than a closure table join?

A: Nested sets make reads simple, but every insert requires shifting the lft/rgt values of every node positioned after the insertion point, an O(n) write against the whole tree. A live comment thread doing thousands of inserts a second cannot tolerate that; the closure table’s O(depth) write cost per insert is the tradeoff that actually fits a write-heavy, insert-only workload.

Q: How do you actually cap infinite nesting in practice, given that real conversations do get deep?

A: The data model itself only imposes a generous safety ceiling, MAX_DEPTH around 500, enforced server-side so a scripted client cannot abuse arbitrarily deep chains to inflate write costs. The product-facing cap is separate and much shallower: MAX_RENDER_DEPTH around 6 in the tree assembly layer, past which the UI shows a “continue this thread” link rather than rendering further nesting inline, which keeps both the response payload and the visual hierarchy manageable regardless of how deep the underlying data actually goes.

Q: Why does the reply count use a Redis buffer instead of updating postgres synchronously on every insert?

A: Updating every ancestor’s counters inside the same transaction as the comment insert makes write latency proportional to depth, since a reply 200 levels deep would need 200 synchronous row updates before it can acknowledge. Buffering increments in Redis and flushing them in small batches decouples submission latency from ancestor depth entirely, at the cost of counters lagging the true value by a few seconds under load, a tradeoff this system explicitly accepts since counts are read-mostly and rarely need to be exactly current to the second.

Q: What happens when a comment near the top of a 50,000-comment thread is deleted? Does it break the ordering or the depth of its replies?

A: No. Deletion is soft: visibility flips to deleted, the body is replaced with a tombstone string, but the comments row and every comment_closure row referencing it as an ancestor stay exactly as they were. Its replies keep a valid ancestor chain, their depth values are unaffected, and their own sort_score and position in their parent’s children list are untouched, since none of that data was ever derived from the parent’s content, only from its comment_id.

Q: Why is sort order maintained per level instead of applying one global order to the whole thread?

A: Because a global order does not match how people actually read threaded conversation. A reader viewing a top-level comment sorted best expects its nested replies to also default to best, not to inherit some unrelated global ranking. Since every read query is scoped to one parent_id and orders by a column on the comment row itself, per-level sorting falls out of the query shape for free, no special composition logic is needed to make nested levels follow the same rule as their parent.

Interview Questions

Q: Walk through what happens, end to end, when a user posts a reply nested 40 levels deep in an existing thread.

Expected depth: Cover fetching the parent’s ancestor chain from comment_closure in one query, computing the new depth as parent.depth + 1, checking it against MAX_DEPTH, building and inserting one closure row per ancestor plus the new comment’s self-row in a single transaction, and why this keeps write latency independent of depth rather than proportional to it.

Q: Design the read API for “load more replies” under a given comment. What does pagination look like, and why keyset instead of offset?

Expected depth: Discuss the keyset cursor as (sort_score, comment_id), why comment_id is needed as a tiebreaker for score ties, why OFFSET degrades as O(offset + page size) on a heavily-replied-to comment while keyset pagination stays O(page size) regardless of how many pages were already fetched, and how the same query and cursor shape serves every sort mode by just swapping the ORDER BY column.

Q: A single post goes viral and its thread grows to 400 writes per second and 50,000 comments within a day. How does the system detect and handle this, and what changes about how that thread is stored?

Expected depth: Discuss per-partition write latency and lock-wait monitoring as the detection signal, sub-sharding the thread’s closure rows by top-level ancestor once it crosses a size threshold, why subtree queries almost never cross a top-level boundary and so this split does not break the read path, and the parallel to hot-key splitting in any other partitioned system.

Q: How would you extend this design to support re-parenting a comment, a moderator merging two duplicate subthreads into one? What does that operation cost in a closure table versus a materialized path?

Expected depth: Cover that re-parenting in a closure table requires deleting every closure row pairing the moved subtree’s descendants with their old ancestors above the move point, and inserting new rows pairing them with the new ancestors, an operation proportional to old_ancestor_count * subtree_size. Contrast with materialized path, where every descendant’s path string must be rewritten, similarly proportional to subtree size, and discuss why this design treats re-parenting as a rare, offline-tolerant operation rather than something the hot path needs to support cheaply.

Q: Explain the tradeoffs of collapsing a subtree client-side versus having the server track collapse state per user.

Expected depth: Discuss that client-local collapse state (a DOM or in-memory flag) is free but does not persist across sessions or devices, while server-tracked collapse via comment_collapse_overrides persists but adds a small write and a join on every read for a user with many overrides. Cover why auto-collapse decisions should be computed from data already returned with the parent comment (sort_score, total_descendant_count) rather than a live query, so deciding what to hide by default never costs an extra round trip.

Premium Content

Unlock the full article along with everything else in the archive — all in one place.

In-depth analysis Expert insights Full archive access
Unlock Full Article