cf.completefrontend
My progress
SYSTEM DESIGN · #46

Design a nested comments thread

A comment section where people reply to replies, as on Reddit or Hacker News. It looks like a list with indentation, but the hard parts are turning a flat list of comments into a tree fast, loading a tree that may hold 20,000 comments a branch at a time, sorting it fairly, posting replies instantly before the server answers, and keeping the page still while new replies arrive.

The question

We run a site with articles, and under each article there is a discussion. People can comment on the article and reply to any comment, and those replies can get replies, as deep as people like. Readers vote comments up or down and can sort the discussion.

Design the frontend of this comments thread. How do you model and load a tree that can grow to thousands of comments, how do you render it so it stays fast and readable on a phone, and what happens when someone posts a reply or when new replies arrive while you read?

Real-life examples: Reddit comment threads, Hacker News discussions, GitHub Discussions replies, YouTube comment replies, Comment sections under blog posts

Requirements: what to ask before drawing anything

A comments thread is a discussion attached to something: an article, a post, a video, a pull request. A top-level comment replies to that thing. A reply answers another comment. Because replies can have replies, the whole discussion is a tree: every comment has one parent (another comment or the article itself) and any number of children.

Products shape that tree in different ways. YouTube and GitHub Discussions allow one level of replies: an answer to a reply lands in the same flat list, often with an @mention. Reddit and Hacker News allow any depth, so a single argument can run 30 levels down. The depth you allow changes the data model, the layout, and the loading strategy, so settle it first.

Start with questions. Each answer below changes something in the design. The middle column is what this guide assumes.

QuestionAnswer we assumeWhat it changes
How deep can replies go?Any depth is stored. We show up to 8 levels on desktop and 4 on phones, then a "Continue this thread" link.Tree model, indentation rules, and a separate view for deep branches.
How big do threads get?Most articles get under 50 comments. A popular one gets 5,000, and a viral one 20,000 or more.We can't load everything at once. Loading must be paged per branch.
How do readers sort?Best (the default), Top, New, and Controversial.Sort is a server concern for paging, with a client rule for where new comments go.
Can people vote?Yes: up, down, or none, once per person per comment.Optimistic vote toggles and an idempotent write.
What can a comment contain?Markdown: bold, italics, links, quotes, code, and @mentions. No raw HTML, no images in this version.A composer with preview, and strict sanitizing of what we render.
Edit and delete?Authors can edit (with an "edited" mark) and delete. Moderators can remove.Soft delete keeps a placeholder so replies don't lose their parent.
Live updates?New comments should appear within a few seconds, without jumping the page.A push channel plus a "new replies" pill rather than inserting rows under the reader's eyes.
Links to one comment?Yes. People share links to a single comment.Permalink view that loads the comment, its ancestors, and some context.
Who reads and who writes?Many readers, few writers: about 100 reads for every comment posted. Search engines read too.Server-render the first page for speed and SEO; optimize reading first.
Which devices?Half the traffic is phones on 4G, many of them mid-range Android.Indentation limits, touch targets, and a small JavaScript budget.
Wireframe of the comments section. At the top, a sort menu set to Best and a pill saying 3 new comments, then an Add a comment box. Below, a comment by maya with 42 points and its actions, a reply by sam indented one level, and a reply by lee indented two levels. Vertical thread lines run down the left of each branch. Under the replies, a Load 8 more replies button. At the bottom, a collapsed comment by ana with 14 replies hidden. Notes on the right name each part.article: 1,842 commentsBest ▾3 new commentsAdd a commentmaya, 3 h ago, 42 pointsReply, vote, share, reportsam, 2 h ago, 9 pointslee, 1 h ago, 3 pointsLoad 8 more replies[+] ana, 1 h ago, 14 replies hiddensort in the URLnew commentswait in a pillthread line:click collapsesmore replies:per-parentcursorcollapsed:total hidden
Indentation and thread lines show who replies to whom. Everything that could move the page (new comments, more replies) waits behind a button the reader presses.

Functional requirements

  • Show the comment count and the first page of top-level comments, each with its first few replies.
  • Show each comment's author, age ("3 h ago"), score, body, and actions: Reply, Vote, Share, Report, and for the author, Edit and Delete.
  • Load more top-level comments, and more replies inside any branch, on demand.
  • Collapse and expand any comment with all its replies.
  • Sort by Best, Top, New, and Controversial.
  • Reply inline under any comment, with markdown, @mentions, and a preview; a posted reply shows up at once.
  • Vote up or down, and change or remove the vote.
  • Open a permalink to a single comment that shows it in context and highlights it.
  • Show new comments from other people without moving what the reader is looking at.

Non-functional requirements, with numbers

  • Fast first read: the first 20 top-level comments are in the server-rendered HTML. Largest Contentful Paint stays under 2.5 seconds on a 4G phone.
  • Responsive: a vote, collapse, or reply shows its result within 100 ms of the tap; Interaction to Next Paint stays under 200 ms even with 2,000 comments loaded.
  • Stable: content never jumps under the reader. Cumulative Layout Shift stays under 0.1, including when new comments arrive.
  • Safe: user content can never run script or fake our UI. Links from comments carry rel="ugc nofollow".
  • Accessible: the tree makes sense to a screen reader, every action works by keyboard, and collapse state is announced.
  • Small: the comments code is its own chunk of about 40 KB compressed, loaded after the article; the composer and markdown preview load on first use.

Out of scope, and why

  • The rich-text editor itself. We use a plain markdown textarea; a real editor is its own problem (see the rich-text editor guide).
  • The ranking and spam models on the server. We say what the client needs from them, not how they work.
  • Notifications by email or push when someone replies to you.
  • Chat-style threads like Slack's, where a thread is a side panel of a channel; the Slack guide covers that shape.

Check your understanding · Exercise 1

Which answers from the interviewer would change the design the most? Select all that apply.

What makes a comments thread hard

A list of comments is easy. A tree of comments that grows while people read it is not. Five properties drive every decision in the rest of this guide.

  1. It is a tree, but networks and screens are flat. JSON responses, database rows, and the DOM all come in order, one after another. Somewhere we must turn "comment 7 replies to comment 3" into the right nesting, and back into rows on the screen.
  2. It is lopsided. In a 5,000 comment thread, one top-level comment may hold 1,200 replies while 300 others have none, and one branch may go 40 levels deep. Any strategy that loads "everything under X" can explode on the wrong X.
  3. It is ranked, and the ranking changes. Best and Top depend on votes, which change every second on a busy thread. Re-sort on every vote and comments jump while you read; never re-sort and new good comments stay buried.
  4. It is live and written by readers. People post, edit, delete, and vote while others read. Each action must feel instant to the person who did it and reach everyone else without shifting their page.
  5. It is untrusted text from strangers. Every comment body can try to run script, fake a login form, hide a phishing link, or spam a link farm. Rendering it is a security problem first and a styling problem second.

A sixth property is easy to miss: reading dominates. Most readers only look at the first screen or two, so we optimize the first page hard (server-rendered, small, stable) and load everything else only when someone asks for it.

A comment as JSON is about 600 bytes (ids, author, timestamps, score, a 300 character body), so a viral thread of 20,000 comments is about 12 MB: several seconds of download even on a good 4G connection, before any parsing. The first page we need, at most 140 comments (the page budget in the loading chapter), is about 84 KB, roughly 15 KB compressed. That gap is why per-branch loading is the core of the design, not an optimization.

Think first

A thread has 5,000 comments. One top-level comment has 1,200 replies. What goes wrong if the API returns each top-level comment with all of its replies embedded?

Modeling the tree: nested JSON or a flat list

The first big decision is the shape of the data, on the wire and in the client. Every later feature (loading more replies, inserting a reply, collapsing, live updates) is an operation on this shape.

The naive approach: nest it all in JSON

The obvious answer is to return the tree exactly as it will be shown: each comment object holds a replies array of comment objects, which hold their own replies, and so on. Rendering is then a recursive component that maps over replies. It works in a demo with 30 comments.

Now add the real features. To insert a reply to a comment 6 levels deep, we walk down from the root to find the parent, then copy every object on the path to keep React's immutable updates. A vote on that comment does the same walk. A live event saying "comment 9123 was edited" gives no hint where 9123 is, so we search the whole tree.

"Load more replies" returns a nested chunk that we must graft into the right spot. Each of these is an O(n) search plus an O(depth) copy, and each re-renders the whole path from the root, so a vote on a deep comment re-renders every ancestor.

Decision How should comments be shaped on the wire and in the client store?

Nested JSON, stored nested

The server returns comments with replies arrays inside.

The client keeps that tree as one nested object and renders it recursively.

Pros

  • Matches the picture; the render code is the shortest.
  • No build step on the client.

Cons

  • Finding a comment by id is a full tree search.
  • Every update copies the path from the root and re-renders it.
  • Paging a branch means grafting nested chunks; the server must build nested JSON, which is awkward from a SQL query.

Flat list with parentId, normalized by id

The server returns a flat array; each comment has id and parentId (null for top level).

The client stores comments in a map keyed by id, plus a list of child ids for each parent, and builds the visible order from those.

Pros

  • Lookup, vote, edit, and insert are O(1) by id.
  • Paging a branch just appends ids to one parent's child list.
  • Live events carry an id and land directly; only the changed comment re-renders.

Cons

  • The client needs a small build step to group children.
  • A child can arrive before its parent (live events, permalinks), so we need a rule for orphans.

Flat list with a materialized path

Each comment carries path, its chain of ancestor ids, such as a1/b7/c3, and depth.

Sorting rows by path gives the tree in display order.

Pros

  • Ancestors are known without fetching them, which is handy for permalinks and breadcrumbs.
  • Subtree queries on the server are a prefix match on the path.

Cons

  • Sorting by path only gives one fixed order (by id or time); Best or Top inside each level needs more work.
  • Paths grow with depth and repeat data in every row.

What we pick: Use the flat list with parentId as the main shape, normalized by id, and also send depth and the ancestor path because they are cheap and solve permalinks and indentation without extra requests. This is the same normalization idea taught in the normalization lesson, applied to a tree: entities by id, relationships as id lists.

A flat list of six comments with parent ids becomes a tree in one pass. The list: c1 with no parent, c2 and c3 with parent c1, c4 with no parent, c5 with parent c2, c6 with parent c4. The tree: the post has children c1 and c4; c1 has c2 and c3; c2 has c5; c4 has c6.flat list from the APIc1, parent nonec2, parent c1c3, parent c1c4, parent nonec5, parent c2c6, parent c4one passpostc1c4c2c3c6c5children grouped by parentId
The API sends rows in order, parents first. One pass groups them by parentId into child lists; the tree is just those lists plus a map of comments by id.

Building the tree in one pass

A common interview trap is building the tree with a nested loop that scans all comments to find each comment's children. That is O(n squared): 20,000 comments means 400 million comparisons, several seconds on a phone. The fix is one pass with a map: for each comment, push its id onto its parent's child list. Map lookups are constant time, so the build is O(n), a few milliseconds for 20,000 comments.

ts
type Comment = { id: string; parentId: string | null; depth: number; body: string };
type Thread = {
  byId: Map<string, Comment>;
  childIds: Map<string | null, string[]>; // null = top level
};

function buildThread(rows: Comment[]): Thread {
  const byId = new Map<string, Comment>();
  const childIds = new Map<string | null, string[]>();
  for (const c of rows) {
    byId.set(c.id, c);
    const siblings = childIds.get(c.parentId);
    if (siblings) siblings.push(c.id);
    else childIds.set(c.parentId, [c.id]);
  }
  return { byId, childIds };
}
One pass, O(n). The server already sends each parent's children in sort order, so we keep arrival order.

Notice that child objects never go inside parent objects. The "tree" is just two maps, so every comment stays a small, flat record that can change without touching its relatives.

Orphans need a rule. A live event may bring a reply whose parent we haven't loaded, because it sits in a collapsed or unloaded branch. We store it in byId anyway; nothing renders it yet, and if the parent is loaded, its "N more replies" count goes up by one. When that branch is fetched later, the record is already there and the page just lists its id in order.

Replies per parentMax depth
Collapse

12 of 12 comments rendered: 0 hidden by collapse, 0 not loaded behind load more buttons (with their replies), 0 behind Continue this thread.

Tree builder. Input: 12 comments as a flat list with parent ids. Output rows: c1; c2; c4; c5; c6; c7; c3; c10; c8; c9; c11; c12. 12 of 12 comments rendered: 0 hidden by collapse, 0 not loaded behind load more buttons (with their replies), 0 behind Continue this thread.flat inputc1 → rootc2 → c1c3 → c1c4 → c2c5 → c4c6 → c5c7 → c6c8 → rootc9 → c8c10 → c1c11 → c8c12 → rootrendered rowsc1c2c4c5c6c7c3c10c8c9c11c12
12 of 12 comments rendered: 0 hidden by collapse, 0 not loaded behind load more buttons (with their replies), 0 behind Continue this thread.

Where each piece of state lives

StateWhereWhy
Comments by id, child id lists, counts and cursorsNormalized store inside the server-state cache (for example TanStack Query data, or a small store fed by it)Shared by every view of the thread; updated by fetches, live events, and optimistic writes.
Sort orderURL query (?sort=best)Shareable and survives reload; changing it is a new query key.
Collapsed comment idsClient store, saved to sessionStorage per threadSurvives scrolling away and coming back, and a reload, but is not worth syncing to the server.
Reply drafts per parentClient store keyed by parent id, saved to localStorageA half-written reply must survive collapsing the branch and an accidental reload.
My vote per commentOn each comment record (myVote)Rendered with the comment; changed optimistically.
Highlighted commentURL (permalink path or #c-id hash)It is what was shared; the URL is the source of truth.

Check your understanding · Exercise 2

You store comments as byId plus childIds per parent. Which operations are now constant time (not counting the re-render)? Select all that apply.

Loading a lopsided tree a branch at a time

We know we can't fetch 20,000 comments. The question is which slice to fetch first, and how a reader asks for more. The slice must look complete (a reader shouldn't feel they are missing the conversation) while staying a predictable size.

Two naive answers, and how they fail

Fetch everything works for the many articles with under 50 comments, then falls over on the one that goes viral: 12 MB, seconds of parsing, and a frozen phone, exactly when the most people are watching. Page only the top level, with no replies, keeps pages small but hides the conversation, because the best part of a thread is often the first reply to a top comment ("Source?", followed by the source).

Decision What does the first request return, and how does the reader get more?

Fetch the whole thread

One request, every comment, flat list.

Client builds the tree and renders it all (or virtualizes).

Pros

  • Simplest; sorting, search, and collapse are all local.
  • Fine for small threads.

Cons

  • Unbounded size: 12 MB for a viral thread.
  • Slow first paint, high memory, and the work is wasted on readers who stop after one screen.

Top-level pages only, replies on click

Page top-level comments with a cursor.

Each comment shows "View 14 replies"; clicking fetches its children.

Pros

  • Small, even pages.
  • Very simple server queries.

Cons

  • The discussion is hidden behind clicks.
  • One request per branch the reader opens, each a round trip of 100 to 300 ms on mobile.

Bounded tree: top-level page with the first few replies per level

Each page holds 20 top-level comments, up to 3 replies under each, and 1 reply under each of those replies. Deeper levels load on demand.

Every comment carries replyCount (direct replies) and a cursor; "load more replies" fetches the next page of one parent's children.

Below the depth limit, a "Continue this thread" link opens that comment as a new root.

Pros

  • The first screen shows real conversation.
  • Size is capped: at most 20 x (1 + 3 + 3) = 140 comments per page, about 84 KB of JSON before compression, however lopsided the tree.
  • Each branch pages on its own.

Cons

  • The server query is more complex (a top-N per parent at each level).
  • More states to render: loaded, partly loaded, and unloaded branches.

What we pick: Use the bounded tree. The page budget is the key idea: we choose the numbers (20, 3, 1, depth 8) so the worst page is small and the typical page looks complete. Reddit's public API works in a similar way: a comment listing returns a limited tree, stubs the rest as "more" items, and the client expands them through the morechildren endpoint. The general cursor technique is in the pagination lesson; here we apply it per parent.

A bounded first page. Top-level comment T1 has 14 replies; three are loaded: R1, R2 and R3. R1 has 5 replies; one, R1a, is loaded, followed by a load 4 more button. After R3 comes a load 11 more replies button for T1. Then top-level comment T2 with no replies, and a button for the next 20 top-level comments. Notes say each top-level comment costs at most 7 comments, so a page of 20 holds at most 140; every load more button has its own cursor and shows total minus loaded; past depth 8, Continue this thread opens a new root.T1, 14 repliesR1, 5 repliesR1aload 4 moreR2R3load 11 more repliesT2, no repliesnext 20 commentspage budget per top-level:1 + 3 + 3 = 7 comments20 per page: at most 140each button: its own cursorcount = total minus loadedpast depth 8:Continue this threadopens that comment as a root
Solid rows came in the first page; hatched rows are buttons. Each button belongs to one parent and pages only that parent's children, so a 1,200-reply branch never makes the first page bigger.

A cursor for every parent

Every parent that has unloaded children needs its own way to ask for "the next ones". Offsets (?offset=3) break here: if a new reply arrives at position 1 while you read, offset 3 now returns a comment you already have, and you miss another. A cursor is an opaque string from the server that encodes "after this item in this sort", so new arrivals don't shift what comes next.

So the client keeps a small record for each parent: the ids loaded so far, the cursor for the next page (or null when there is nothing more), the total count, and a loading flag. The top level is just the parent with id null.

ts
type ChildPage = {
  ids: string[];           // loaded children, in display order
  nextCursor: string | null;
  total: number;           // direct replies on the server
  status: "idle" | "loading" | "error";
};

// "Load 11 more replies" = total - ids.length
function remaining(p: ChildPage): number {
  return Math.max(0, p.total - p.ids.length);
}
Per-parent paging state. The UI derives "Load 11 more replies" from total minus loaded.

Two counts, not one. replyCount is the number of direct replies; descendantCount is the whole subtree. Show the first on a "load more" button (it's what the button fetches), and the second on a collapsed comment ("14 replies hidden"), because collapsing hides everything below. Mixing them up produces buttons that promise 40 replies and deliver 3.

Deduplicate on arrival. A reply you posted optimistically, or that came in on the live channel, may show up again in the next page from the server. Before appending ids, skip any already in the parent's list. The normalized store makes this a set lookup.

When the tree is too deep: continue this thread

Past a depth limit, indentation stops making sense: at depth 10 on a 360 pixel phone, a 16 pixel indent per level eats 160 pixels and leaves about 25 characters a line. Reddit's answer is a "Continue this thread" link that opens the deep comment as the root of a new view, with a link back up.

The client needs no special API for this: the link is the deep comment's permalink (see the permalinks chapter), and that view counts depth from the new root. Each comment's depth from the server tells us when to stop nesting and show the link instead.

Check your understanding · Exercise 3

A comment shows "Load 8 more replies". While the reader looks at it, 2 new replies arrive on the live channel and are added to the list. Which statements are right? Select all that apply.

Sorting: Top, New, Controversial, and Best

Sorting applies at every level of the tree separately: top-level comments are sorted among themselves, and each comment's replies are sorted among themselves. A reply never moves to a different parent. That is why the server sorts each parent's children and the client keeps that order.

Why the server must sort

Sorting on the client only works if the client has every comment, which we just ruled out. If the client sorts the 20 comments it has, the "top" comment may actually be number 300 on the server. So the sort is part of the query (?sort=best), each sort has its own cursors, and switching sort throws away the loaded pages and fetches again. With a server-state library this is natural: the sort is part of the query key, so each sort is cached separately and switching back is instant.

The four sorts, in plain words

SortRuleGood forProblem
NewNewest first, by creation time.Following a live discussion.Noise: the newest is rarely the best.
TopHighest ups - downs first.Seeing the most liked comments of an old thread.Rich get richer: early comments collect votes and stay on top; a great late comment never surfaces.
ControversialMany votes, split close to half and half.Finding arguments.Meaningless on small numbers.
BestThe lower bound of a confidence interval on the share of upvotes.A default that is fair to new comments with few votes.Harder to explain; needs a formula on the server.

Best needs more explanation, because interviewers like to ask about it. Reddit added it in 2009, based on Evan Miller's article "How Not To Sort By Average Rating". A comment with 10 upvotes and 0 downvotes is 100% liked, and one with 900 up and 100 down is 90% liked, but we are far more sure about the second, which has 100 times more votes.

Best asks: given the votes so far, what is the lowest share of upvotes this comment probably has? It uses the Wilson score interval, a statistics formula that gives a range for a true proportion from a sample, and sorts by the bottom of that range. With few votes the range is wide, so the bottom is low; as votes come in, the range narrows toward the real share.

Reddit's open-source code uses 80% confidence. Here are three comments scored that way:

Top puts C first because it has the most net votes. Best puts B first: 90% liked with enough votes to trust it. Controversial puts C first: 1,000 votes split 60/40.
CommentUp / downTop (up - down)Best (Wilson lower bound)Controversial
A2 / 020.550
B90 / 10800.851.7
C600 / 4002000.58100

Reddit's controversial score is (ups + downs) raised to the power of min / max of the two counts. Many votes with a near even split scores high; any comment with no downvotes (or no upvotes) scores 0. The same code also has Hot, the log of the net score plus a time term, so an item 12.5 hours newer needs 10 times fewer net votes to rank level; it is mainly used for posts rather than comments.

Presets
Sort lab. Votes: A 2 up, 0 down; B 90 up, 10 down; C 600 up, 400 down. Rankings, first to last. Top: C 200, B 80, A 2. Best: B 0.855, C 0.580, A 0.549. Controversial: C 100.0, B 1.7, A 0.0.TopC: 200B: 80A: 2BestB: 0.855C: 0.580A: 0.549ControversialC: 100.0B: 1.7A: 0.0first place is outlined; the server sorts, the client keeps the order
Top: C 200, B 80, A 2. Best: B 0.855, C 0.580, A 0.549. Controversial: C 100.0, B 1.7, A 0.0. Best uses 80% confidence, as Reddit's code does.

None of this math runs on the client. The client's job is to send the sort, keep the server's order, and decide what to do with comments that appear later, which is the harder part.

Stable order while the thread moves

Votes change scores every second. If the client re-sorted on every live score update, a comment you are reading would slide down the page because someone else downvoted it. So the rule is: the order is frozen when a page is loaded; scores update in place, positions don't. The order changes only when the reader asks (changes sort, presses refresh, or reloads).

New comments need a placement rule too, and it differs by sort:

  • My own new reply: always directly under its parent, as the first child, whatever the sort, because people expect to see what they wrote where they wrote it. After a reload it moves to its real place.
  • Someone else's new reply, sort New: it belongs at the top of its parent's children, but we don't insert it under the reader's eyes; we show a pill (chapter on live updates).
  • Someone else's new reply, sort Best or Top: its true position depends on votes it doesn't have yet, probably near the end. Count it in "N more replies" and let the reader load it.

Check your understanding · Exercise 4

Comment X has 10 upvotes and no downvotes. Comment Y has 90 upvotes and 10 downvotes. Which statements are true? Select all that apply.

Rendering deep trees: indentation, collapse, and virtualization

We now have comments in a normalized store and a child list per parent. The next decision is how that becomes DOM. There are two shapes: render the tree as nested elements (a comment element contains its replies' elements), or flatten it into one list of rows, each with a depth number that sets its indent.

Decision How do we turn the comment tree into DOM?

Recursive nested components

A Comment component renders its body, then maps its child ids to more Comment components inside a nested list.

Collapse hides the nested list.

Pros

  • The DOM matches the meaning: replies are inside their parent, which helps screen readers and works without JavaScript after SSR.
  • Collapse is one hidden attribute on the child list.
  • Simple and fast up to a few thousand comments.

Cons

  • Hard to virtualize: virtualization libraries expect a flat list of rows.
  • Very deep trees mean very deep DOM, with style recalculation costs.

Flattened rows with a depth

Walk the tree depth-first, skipping collapsed subtrees, to produce rows: { id, depth }, plus special rows like "load 8 more".

Render rows in one list, indenting each by its depth.

Pros

  • Works directly with a virtualizer: only the 20 to 40 rows on screen exist in the DOM.
  • Collapse just drops rows from the walk.

Cons

  • The DOM no longer nests, so we must add tree meaning back with ARIA (aria-level, aria-setsize, aria-posinset).
  • Thread lines that span many rows must be drawn per row.

What we pick: Start with recursive nested components. Most threads have under 500 loaded comments, and the semantic nesting is worth a lot for accessibility and SSR. Switch the client to flattened, virtualized rows only when the loaded count passes a threshold (say 1,500), or for a dedicated "view all" mode. Because both renderers read the same normalized store, this is a rendering swap, not a data change.

Indentation that fits a phone

Indentation shows who replies to whom, so we can't drop it, but we must cap it. Use about 12 to 16 px per level on phones and 24 px on desktop, and stop indenting past a maximum visual depth: 4 on phones, 8 on desktop. Beyond that, keep the indent flat with a small "replying to @name" label, or show "Continue this thread".

css
.thread { --indent: 12px; --max-depth: 4; }
@media (min-width: 64rem) {
  .thread { --indent: 24px; --max-depth: 8; }
}
.thread ul { padding: 0; list-style: none; } /* add role="list": Safari drops list semantics without it */

/* The component sets the depth from data: style="--depth: 3" */
.comment-content {
  --depth: 0;
  padding-inline-start: calc(min(var(--depth), var(--max-depth)) * var(--indent));
}
Each comment's own content (header, body, actions) indents by its depth, capped per screen size. Lists and the elements that wrap replies add no padding, so indents never add up, and the same rule works for nested and flattened rendering. The logical property keeps right-to-left languages correct.

Thread lines you can click

Reddit draws a vertical line down the left of each comment's replies, and clicking the line collapses that comment. When you are 40 replies deep into a branch you don't care about, the collapse button is far above you, off screen; the line is next to you all the way down.

Make the line a real button (thin, with an invisible hit area of at least 24 by 24 CSS pixels), labelled "Collapse comment by @maya" with aria-expanded. After collapsing from deep inside a branch, scroll so the collapsed comment's header is at the top of the viewport; otherwise the content the reader was looking at vanishes and they land somewhere random.

Remembering what was collapsed

Collapse state is a Set of comment ids in the client store, not a flag on each comment record: it is view state, and it must survive a refetch of the comment. Save it to sessionStorage under the thread id so a reload or back navigation keeps it. Some comments start collapsed: those below a score threshold (as Reddit does for heavily downvoted comments) and those from people the reader has blocked. A collapsed comment shows the author, score, age, and "14 replies hidden", using descendantCount.

Flattening a tree for virtualization. On the left, the loaded tree: c1 with replies c2 and c3; c2 is collapsed, so its replies c5 and c7 are skipped; c4 with reply c6 and a 3 more button. On the right, the rows the walk produces: c1 depth 0, c2 depth 1, c3 depth 1, c4 depth 0, c6 depth 1, and a more row with 3 at depth 1. A dashed box marks the three rows in the viewport, which are the only ones in the DOM.loaded treec1c2, collapsedc5c7c3c4c63 moreflattenrowsc1, depth 0c2, depth 1c3, depth 1c4, depth 0c6, depth 1more: 3, depth 1dashed: in the viewport and DOM
Collapse just means the walk skips a branch. The virtualizer then mounts only the rows near the viewport and measures each one, because comments vary from 60 px to 600 px tall.

Virtualizing a tree

When we do flatten, the walk is cheap: one depth-first pass over loaded ids, skipping children of collapsed ids, O(loaded comments). Recompute it only when the structure changes (new ids, collapse, expand), never on a vote or edit, by memoizing on the child-list objects. Then hand the rows to a virtualizer as described in the virtualization lesson.

The hard part is height. A one-line comment is 60 px, a long one with a code block 600 px, and an open reply box adds 180 px. The virtualizer starts from an estimate and measures rows after they render (TanStack Virtual's measureElement uses a ResizeObserver). When a row above the viewport turns out taller than estimated, the virtualizer must shift the scroll position by the difference or the content jumps; TanStack Virtual does this by default.

Two more traps: browser find (Ctrl+F) can't see rows that aren't in the DOM, and a permalink target must be scrolled to by index, not by id in the DOM. These are the reasons we don't virtualize by default.

ts
type Row =
  | { kind: "comment"; id: string; depth: number }
  | { kind: "more"; parentId: string | null; depth: number; count: number };

function flatten(t: Thread, pages: Map<string | null, ChildPage>,
                 collapsed: Set<string>, parentId: string | null = null,
                 depth = 0, out: Row[] = []): Row[] {
  const page = pages.get(parentId);
  for (const id of t.childIds.get(parentId) ?? []) {
    out.push({ kind: "comment", id, depth });
    if (!collapsed.has(id)) flatten(t, pages, collapsed, id, depth + 1, out);
  }
  const left = page ? remaining(page) : 0;
  if (left > 0) out.push({ kind: "more", parentId, depth, count: left });
  return out;
}
Flatten the loaded tree into rows, skipping collapsed branches and adding "load more" rows.

Recursion depth here equals the depth of the loaded tree, which paging keeps far below the thousands of frames JavaScript engines allow. Per-comment re-renders matter more: each CommentNode reads its own record from the store by id (a selector), so a vote on one comment re-renders one row, not the list.

Check your understanding · Exercise 5

A reader collapses a top-level comment that has 300 loaded descendants. Which statements fit the design above? Select all that apply.

Writing and posting replies: drafts, preview, and optimistic posts

Replying happens in place. Pressing Reply on a comment opens a box right under it, so the reader can still see what they are answering. This sounds trivial, and the naive version is one boolean: replyingTo: string | null, with a single textarea that moves to whichever comment you clicked.

Here is how that breaks. Maya writes three paragraphs answering comment A, scrolls up to check a quote in comment B, and taps Reply there by mistake: the single box moves to B and her text is gone. Or she collapses A's branch, the box unmounts, and the text is lost. Losing a written reply is the fastest way to make someone stop commenting.

One draft per parent, outside the component

Keep drafts in the client store, keyed by parent id: a map from parent id to { text, updatedAt }. Any number of reply boxes may be open at once, each reading and writing its own draft. Collapsing a branch unmounts the box, but the draft stays in the store and comes back when the branch expands.

Save drafts to localStorage under the thread id, debounced about 500 ms after the last keystroke, so a crash or reload doesn't lose them. Delete a draft on a successful post or an explicit Discard, and expire old drafts after a week so storage doesn't grow forever.

A collapsed comment that holds a draft should say so ("Draft reply") so people find their text again. And when the reader tries to leave the page with unsent drafts, a beforeunload prompt is reasonable, but only when a draft has real content.

Markdown, mentions, and preview

The composer is a plain textarea with markdown. A full rich-text editor is a separate design problem (see the rich-text editor guide), and most comment sites do well with markdown plus a small toolbar that inserts syntax: bold, link, quote, code. One catch: setRangeText inserts at the cursor but bypasses the browser's undo history, while the deprecated document.execCommand("insertText") still keeps Ctrl+Z working in current browsers, so toolbars often try it first and fall back to setRangeText.

Mentions: typing @ma opens a suggestion list ranked with thread participants first (the parent's author, then others in the thread), then a server search debounced by about 150 ms. Selecting one inserts @maya. The server resolves mentions to users when the comment is saved, so its own rules apply (the user exists and hasn't blocked the author) whatever a crafted request sends. The list is an ARIA combobox; the @-mention input problem covers it in depth.

Preview: a Write and Preview toggle. The preview must use exactly the same markdown rules and sanitizer as the server, or people post something that looks different from what they previewed. Share one markdown library and configuration between server and client, and load it with a dynamic import() only when the reader first opens a composer, since most readers never write.

  • Validate length as you type ("9,812 of 10,000") and again on the server: the client check is for kindness, the server check is the rule (see the input validation lesson).
  • Disable Post while the text is only whitespace.
  • Ctrl+Enter or Cmd+Enter posts; Escape closes the box only if it is empty, otherwise asks.
  • Focus moves into the box when it opens, and back to the Reply button when it closes without posting.

Think first

Maya has a draft under comment A. Meanwhile a moderator deletes comment A. What should happen to her draft?

Posting a reply optimistically

When Maya presses Post, she should see her reply under the parent at once, not after a spinner. The round trip on a mobile network is 300 ms to 1 s, sometimes far more. This is the pattern from the optimistic updates lesson, with three twists: the new item has no id yet, it must go to a specific place in a tree, and posting is not naturally idempotent.

Temporary ids and reconciliation

The client creates the comment record before the server has seen it, with a temporary id such as tmp_ plus crypto.randomUUID(). It is inserted into byId and at the front of the parent's child list, marked status: "sending". The UI shows it slightly dimmed with "Posting...", and disables Reply, Edit and Vote on it (there is nothing on the server to reply to yet).

When the server answers with the real comment (id c_9f3), we reconcile: replace the temporary record with the server's version (sanitized body, real timestamp, real id), swap the id in the parent's child list at the same position, and delete the temporary entry. Keep a small map from temporary id to real id for a few seconds, because other code may still hold the old id: a focused element, a scroll anchor, or a pending edit.

The row keeps a stable React key (the temporary id, carried over as a clientKey), so it does not unmount and remount at the swap, which would drop focus and replay animations.

Order of events matters. The live channel may deliver "new comment c_9f3" before the POST response arrives, and adding it would show the reply twice. So the POST carries the temporary id as a clientId, the server echoes it on both the response and the live event, and whichever arrives first reconciles; the second finds nothing to do.

Sequence diagram of posting a reply, between the user, the composer, the store, the API, and the live stream. The user presses Post. The composer inserts a temporary comment tmp_1 marked sending into the store, and the reply shows at once, dimmed. The composer sends POST with idempotency key tmp_1. The live stream delivers comment c_9f3 created with clientId tmp_1, and the store swaps the ids. The API's 201 response then arrives and finds nothing left to do. In the failure path, the POST fails with a network error, the store marks the comment failed and keeps the text, and the user presses Retry, which sends POST again with the same key.UserComposerStoreAPIStreamPostadd tmp_1dimmed rowPOST tmp_1c_9f3, tmp_1201: no-opfailure pathno responsemark failedretry tmp_1
The reply is on screen before any network call. Whichever arrives first, the live event or the response, reconciles tmp_1 into c_9f3; a retry reuses the key, so the server never creates a second comment.

Failure and retry

Posts fail: the network drops, the server rejects the content (too long, a blocked link, rate limit), or the parent was deleted. The temporary comment must not vanish, because it holds Maya's text: it changes to status: "failed" with a reason and two buttons, Retry and Edit. Validation errors (most 4xx) show the reason without Retry, since sending the same text again won't help, and a 429 offers Retry only after its Retry-After time. Network errors and 5xx offer Retry, and retry once automatically when the browser fires online.

Retrying a POST risks a double post: the first request may have reached the server and only the response was lost. So every POST carries an idempotency key header, the same clientId for every retry of that comment. The server stores keys for a day; if it sees the key again, it returns the comment it already created instead of creating another.

State machine of a posted comment. Draft goes to sending when the user presses Post. Sending goes to confirmed, with the real id and the same client key, when the 201 response or the live event arrives. Sending goes to failed on a 4xx, a 5xx, or going offline; failed keeps the text and shows Retry and Edit. Retry goes back to sending with the same key. Edit goes back to draft with the text in the reply box.draftsendingtmp id, dimmedconfirmedreal id, same keyfailedtext keptPost201 or live event4xx, 5xx, offlineRetry, same keyEdit: text back in the box
Failed is a resting state, not an error to hide: it holds the person's words until they choose Retry or Edit. Only a successful post deletes the draft.
ts
async function postReply(parentId: string | null, body: string) {
  const clientId = "tmp_" + crypto.randomUUID();
  store.insertFirstChild(parentId, {
    id: clientId, clientKey: clientId, parentId, body,
    author: me, status: "sending", score: 1, myVote: 1,
  });
  try {
    const res = await fetch(`/api/threads/${threadId}/comments`, {
      method: "POST",
      headers: { "Content-Type": "application/json", "Idempotency-Key": clientId },
      body: JSON.stringify({ parentId, body, clientId }),
    });
    if (!res.ok) throw await toPostError(res); // 4xx: reason; 5xx: retryable
    const saved: ServerComment = await res.json();
    store.reconcile(clientId, saved); // swap id in place, keep clientKey
    drafts.delete(parentId);
  } catch (err) {
    store.markFailed(clientId, err); // keeps the text, shows Retry / Edit
  }
}
Posting with a temporary id and an idempotency key. The store functions are the normalized-store helpers. Retry sends the same request again with the stored clientId, never a new one.
SortServer

Press Post reply. The reply appears at once, under the parent, with a temporary id.

Optimistic reply lab, sorted by best. Replies shown under the parent: c_41, score 30; c_42, score 12; c_43, score 4. Copies of your reply on the server: 0. In flight: nothing.maya, parent commentc_41, score 30c_42, score 12c_43, score 4in flight: nonesort: bestserver copiesof your reply: 0pinned rows stayfirst until reload
Press Post reply. The reply appears at once, under the parent, with a temporary id.

Check your understanding · Exercise 6

Maya posts a reply on a train. The request reaches the server, but the response is lost when the train enters a tunnel. She taps Retry. Which pieces of the design prevent a duplicate comment? Select all that apply.

Editing, deleting, and voting

Deleting without breaking the tree

The naive delete removes the comment and its record. In a flat list that's fine. In a tree it orphans every reply: 40 replies now point at a parentId that doesn't exist, and they either vanish with it or float up to the top level, out of context.

Decision What happens to a comment's replies when it is deleted?

Hard delete the subtree

Deleting a comment deletes all its replies.

Pros

  • Simple; no placeholders.

Cons

  • One person can erase 40 other people's words. Unacceptable for anyone but moderators removing spam.

Soft delete with a placeholder

The server keeps the comment's position but drops its body and author.

The client renders "[deleted]" in its place; replies stay attached under it.

A deleted comment with no replies can simply disappear.

Pros

  • Replies keep their context and position.
  • Counts stay correct.

Cons

  • Placeholder rows take space; long chains of them are noise (collapse them by default).

Re-parent the replies

Move the replies up to the deleted comment's parent.

Pros

  • No placeholders.

Cons

  • Replies now look like answers to a different comment, which changes their meaning. Confusing and sometimes harmful.

What we pick: Soft delete, as Reddit and Hacker News do. The server must actually remove the text and author rather than leave them in the payload for the client to hide, and the label distinguishes "deleted by author" from "removed by a moderator".

On the client, deleting is optimistic like posting: the comment turns into the placeholder at once, with Undo available for about 5 seconds in a toast; the DELETE request is sent after that window, or right away with a server-side undo. Either works; the key is that undo is cheaper than a confirmation dialog for an action people rarely regret.

Editing, and the edit history

Editing reuses the composer in place of the comment body, prefilled with the original markdown (so the server must return the markdown source to the author, not only the rendered HTML). The save is optimistic: show the new body at once with "Saving...". Send the version the edit started from (If-Match: "7"); if a moderator changed the comment meanwhile, the server answers 409 Conflict and the client keeps the author's text in the editor with a message instead of overwriting.

Show an "edited" mark with the time of the last edit. Reddit shows only the mark, while GitHub also lets readers open the full edit history. Edit history matters when people quote each other: if a comment says "I never said that", readers can check. A short grace period (a few minutes after posting during which edits don't count) avoids marking typo fixes.

Votes

Voting works like the poll widget guide's vote and the like button problem, so here are only the parts specific to comments. Each comment has score and myVote (-1, 0, or 1). Clicking up when myVote is 0 sets it to 1 and adds 1 to the score; clicking up again sets it back to 0, and switching from down to up adds 2.

  • Send the state, not the action. PUT /comments/c_9f3/vote with { "value": 1 } is idempotent: sending it twice leaves the same result. A POST /upvote that toggles is not: a retry flips it back.
  • Coalesce fast clicks. If someone clicks up, down, up within a second, send only the final value. Keep the last value acknowledged by the server, so a failed request can roll back to it, not to whatever was on screen before the last click.
  • Live score updates don't overwrite my pending vote. If a live event says score 41 while my vote of +1 is in flight, show the server score plus my unacknowledged change.
  • Rate limit and sign-in. Logged-out readers see the arrows but get a sign-in prompt; the server rejects votes on one's own comments, and the client hides those arrows.
Sequence diagram of voting, between the user, the vote button, the store, and the API. The user clicks up. The button sets myVote to 1 and score to 42 in the store, and the arrow fills at once. The user quickly clicks down and up again. The button sends one PUT with value 1. The API replies 200 with score 42. In the failure path, the API replies 500, and the store rolls back to the last value the server acknowledged: no vote and a score of 41.UserVoteButtonStoreAPIclick upvote +1, 42filled, 42down, upPUT 1, once200, score 42failure path500back to 0, 41
The request carries the final state, not the click, so three quick clicks become one PUT and a retry can't flip the vote. On failure, roll back to what the server last confirmed.

Check your understanding · Exercise 7

Which choices protect the rest of the thread when one comment changes? Select all that apply.

Live updates without moving the page

Other people post, edit, delete, and vote while you read. Two questions: how do those changes reach the browser, and what does the page do when they arrive?

Getting changes to the browser

Decision How should the client learn about new comments, edits, and score changes?

Polling

Every 30 seconds, while the tab is visible, ask GET /api/threads/t1/changes?since=cursor.

The server returns everything changed since the cursor, plus a new cursor.

Pros

  • Works everywhere, through every proxy, with plain HTTP caching.
  • Easy to throttle: slow down when the tab is hidden (visibilitychange) or when the thread is quiet.

Cons

  • Up to 30 seconds late.
  • Many empty responses on quiet threads.

Server-Sent Events

An EventSource connection to /api/threads/t1/stream receives comment.created, comment.updated, comment.deleted, and batched scores events.

On reconnect the browser sends Last-Event-ID, so the server can replay what was missed.

Pros

  • Push within a second or two.
  • One direction is all we need: writes go through normal HTTP requests.
  • Reconnect and resume are built into EventSource.

Cons

  • One open connection per reader; the server needs a fan-out layer for a thread with 50,000 readers.
  • Each SSE stream holds one of the browser's 6 connections per origin on HTTP/1.1; over HTTP/2 streams share one connection, with a negotiated limit (often 100).

WebSocket

A socket shared by the whole app, subscribed to the thread's topic.

Pros

  • Two-way and low latency; reuse it if the site already has one for chat or notifications.

Cons

  • More infrastructure than this feature needs on its own: custom reconnect, heartbeats, and resume logic.

What we pick: Server-Sent Events for open threads, falling back to polling when the stream fails, and polling only (every 60 seconds or more) for threads older than a day, where little happens. Use a WebSocket only if the app already keeps one open. The server-sent events and websockets lessons compare these in depth; for comments, the data only flows to the reader, which is exactly what SSE is for.

Score changes deserve batching: on a busy thread, hundreds of votes per second would mean hundreds of events. The server sends one scores event every 5 to 10 seconds with only the changed scores, and the client applies them in one store update.

What the page does with a new comment

The naive approach inserts every new comment where it belongs. If a new top-level comment lands above the reply someone is halfway through, that reply jumps down 120 px and they lose their line. Five new comments a second make the page unreadable, and every insert above the viewport is a layout shift that counts against CLS.

Instead, we keep new comments in the store but out of the rendered order, and show a count: a small pill fixed at the top of the comment section, "3 new comments", and inside a branch, "2 new replies" on the parent. Clicking the pill inserts them (at the top for sort New, or after a refresh of the order for Best) and scrolls to the first one. The reader chooses when the page changes.

  • Exceptions: the reader's own comments appear immediately (they caused them). A reply to the reader's own comment can appear inline too, highlighted, since they are waiting for it.
  • Edits and deletes apply in place, since they don't change positions much. If an edit makes a comment much taller while it is above the viewport, CSS scroll anchoring (overflow-anchor, on by default in Chromium and Firefox) keeps the visible content still. Safari added it only in version 27, so for older Safari adjust scrollTop by the height change yourself.
  • Announce counts politely: the pill text sits in an aria-live="polite" region, updated at most every 10 seconds, so screen reader users hear "3 new comments" without a stream of interruptions.
Two ways to show three new comments. On the left, inserting at once: three new comments appear above the reply the reader was reading, which is pushed down by about 150 pixels. On the right, a pill: a button saying 3 new comments appears at the top, and the reply the reader was reading stays where it was.insert at oncenew commentnew commentnew commentthe reply you were readingpushed down about 150 pxpill3 new commentsthe reply you were readingnext commentnothing moved
Same data, different moment of change. The pill lets the reader decide when the page moves, which keeps their place and keeps layout shift at zero.

Connection state

The live channel is a small state machine: connecting, live, reconnecting with backoff, and polling fallback. When the tab is hidden for more than a few minutes, close the stream to save the server and the battery. When it becomes visible, open a new stream with the last event id in the URL (only the browser's own reconnects send the Last-Event-ID header), and if the server can't replay that far back, fetch the changes endpoint once. A small "Live" dot that turns grey when not connected helps readers trust the counts.

Check your understanding · Exercise 8

A reader is in the middle of a long thread sorted by New. Twelve new top-level comments arrive in a minute. What should happen? Select all that apply.

Architecture and interface: the pieces, the data flow, and the API

We have made the big decisions; now we name the parts and draw the boundaries between them. The guiding rule: one normalized store owns comment data, and every source of change writes into it (page fetches, live events, optimistic writes). Components only read from the store by id and send intentions (post, vote, collapse) to small services.

Architecture of the comments section. Inside the browser zone: CommentsSection at the top, the Composer, a lazy chunk, which posts through the API client; the API client puts pages and saved comments into the Thread store; the Live channel, using SSE with a polling fallback, puts events into the Thread store; and CommentNode reads one comment by id from the store, shown to the reader. Outside the browser: the API client sends reads through a CDN, which caches GETs and passes misses to the Comments API, and sends writes straight to the Comments API; the Comments API publishes events to the Stream service, which sends them over SSE to the Live channel.readerBrowser: comments islandCommentsSectionComposerlazy chunkCommentNodememo by idAPI clientkeys, retriesThread storebyId, pagesLive channelSSE, fallbackpostpagesby ideventsCDNcaches GETsComments APIpages, writesStreamSSE fan-outmissevents
Every source of change writes into one store: pages, live events, and optimistic writes. Components only read by id and send intentions, so a vote re-renders one comment.
PartResponsibilityWhy the boundary is here
CommentsSectionEntry point under the article: count, sort menu, new-comments pill, top-level list, top-level composer.Lazy loaded as its own chunk after the article; owns the thread id and sort.
CommentNode (or CommentRow when virtualized)One comment: header, body, actions, thread line, reply box slot, child list.Reads one record by id; memoized, so unrelated changes don't re-render it.
ComposerTextarea, toolbar, mentions, preview, validation; reads and writes a draft by parent id.Code split: most readers never open it. Same component for replies, top level, and edits.
Thread storebyId, child pages per parent, collapsed ids, drafts, pending temp ids.One place where pages, live events, and optimistic writes merge and dedupe.
API clientTyped calls for pages, context slices, post, edit, delete, vote, report; idempotency keys; error mapping.Keeps HTTP details and retries out of components.
Live channelSSE connection, reconnect with backoff, polling fallback, visibility handling; emits typed events to the store.Transport can change (SSE, WebSocket, polling) without touching the store.
Markdown rendererSame parser and sanitizer config as the server, used for preview only.Loaded with the composer; never used to render other people's comments, which arrive as sanitized HTML.

The comments section is an island under a mostly static article: both are server-rendered HTML, and the comments JavaScript hydrates only that section, after the article is interactive. On a React stack this is a client component boundary at CommentsSection, with the first page passed in as data from the server.

How data moves end to end

The read path: the server renders the article and the first comment page into HTML, and embeds the same page as JSON for hydration so the client doesn't refetch it. After hydration, the client opens the live stream from that page's stream cursor. "Load more" calls go through the API client and the CDN, which can cache them for a few seconds for logged-out readers, since a popular thread is read by thousands at once.

The write path: the composer inserts a temporary comment into the store, the API client posts it straight to the Comments API, and the response and the live event both reconcile into the store.

Data flow for the comments section. The Comments API sends page JSON to the SSR server, which sends HTML with the embedded page to the browser. The browser hydrates the Thread store from that JSON. Components read comments by id from the store. When the reader loads more, posts, or votes, components call the API client, which sends POST and PUT requests to the Comments API, and GET requests for more pages through the CDN. Results from the API client go into the store, and the Stream service sends events into the store too.Comments APIpages, writesSSR serverarticle + page 1BrowserHTML on screenThread storebyId, pagesComponentsread by idAPI clientkeys, retriesCDNcached GETsStreamSSE eventsJSONHTMLhydrateby idreads, writesGETmissPOST, PUTevents
The first page crosses the network once, inside the HTML. After that, reads go through the CDN, writes go straight to the API, and every result, including live events, lands in the store.

Personal data is the tricky part of caching: the comment page is the same for every logged-out reader, but myVote and "can edit" differ per person. Serve the shared page (cacheable) and fetch a small personal overlay (GET /api/threads/t1/me?ids=... returning my votes and my comment ids) that the store merges in. Logged-out readers skip the overlay.

Check your understanding · Exercise 9

Why does every source of comments write into one normalized store instead of each component keeping its own list? Select all that apply.

Interface: data types, API, and component props

The client types are what the store holds; the API types are what crosses the network. Keeping them separate lets the client add view fields (status, clientKey) without changing the server contract.

ts
type ServerComment = {
  id: string;                 // "c_9f3"
  parentId: string | null;    // null = top level
  path: string[];             // ancestor ids, root first
  depth: number;              // 0 for top level
  author: { id: string; name: string; avatarUrl: string } | null; // null if deleted
  bodyHtml: string | null;    // sanitized HTML; null if deleted or removed
  state: "visible" | "deleted" | "removed" | "collapsed_low_score";
  score: number;
  replyCount: number;         // direct replies
  descendantCount: number;    // whole subtree
  replies?: { nextCursor: string | null }; // where to continue this parent's children
  createdAt: string;          // ISO 8601
  editedAt: string | null;
  version: number;            // for If-Match on edits
  clientId?: string;          // echoed from the POST that created it
};
What the API sends for each comment. Bodies come pre-rendered and sanitized.
http
GET /api/threads/t1/comments?sort=best&parentId=root&limit=20&replies=3&depth=2
GET /api/threads/t1/comments?sort=best&parentId=c_42&cursor=eyJzIjo0Mi4xfQ&limit=10
GET /api/threads/t1/comments/c_9f3/context?ancestors=all&replies=3

200 OK
Cache-Control: public, max-age=5, stale-while-revalidate=30
{ "comments": [ /* flat list, parents before children */ ],
  "pages": { "root": { "nextCursor": "eyJ...", "total": 1840 } },
  "streamCursor": "ev_88213" }
Reading: one endpoint for pages of any parent, one for a permalink's context.
http
POST   /api/threads/t1/comments        Idempotency-Key: tmp_6c1e...
       { "parentId": "c_42", "body": "markdown...", "clientId": "tmp_6c1e..." }
PATCH  /api/comments/c_9f3              If-Match: "7"
       { "body": "edited markdown" }
DELETE /api/comments/c_9f3
PUT    /api/comments/c_9f3/vote         { "value": 1 }    // -1, 0 or 1
POST   /api/comments/c_9f3/reports      { "reason": "spam" }

Errors: { "error": { "code": "parent_removed", "message": "...", "retryable": false } }
        409 version_conflict, 413 too_long, 422 blocked_link, 429 rate_limited (Retry-After)
Writing: idempotent where it can be, versioned where it must be.
text
id: ev_88214
event: comment.created
data: { "comment": { "id": "c_a01", "parentId": "c_42", ... }, "clientId": "tmp_6c1e..." }

id: ev_88215
event: comment.updated
data: { "id": "c_9f3", "bodyHtml": "...", "editedAt": "...", "version": 8 }

id: ev_88216
event: scores
data: { "c_42": 118, "c_9f3": 7, "c_a01": 2 }
Live events on the SSE stream. Each has an id so a reconnect resumes with Last-Event-ID.

Two contract details matter: pages are flat lists with parents before children, so the one-pass build works, and bodies arrive as sanitized HTML made on the server, so the client never parses other people's markdown (the safety chapter explains why).

Component props

If the comments section is a reusable component (used under articles, videos, and products), its props are the integration contract for other teams. Keep them small: what thread, who is reading, and hooks for the host page.

tsx
type CommentsSectionProps = {
  threadId: string;
  initialPage?: CommentsPage;          // from SSR, avoids a refetch
  initialSort?: "best" | "top" | "new" | "controversial";
  focusCommentId?: string;             // permalink target
  viewer: { id: string; name: string } | null; // null = logged out
  maxVisualDepth?: { mobile: number; desktop: number }; // default 4 and 8
  onRequireSignIn?: (action: "reply" | "vote" | "report") => void;
  onCountChange?: (total: number) => void; // the article header shows it
};
The embedding contract. Everything else is internal.

Check your understanding · Exercise 10

Which API choices make the client simpler or safer? Select all that apply.

Safe rendering and moderation

Every comment is text from a stranger that we place inside our page, next to our login button. If one comment can run script, it runs as our site in every reader's browser, acting with their session: posting comments as them, voting, or drawing a fake "session expired, log in again" form. That is stored cross-site scripting, especially dangerous because one post attacks every reader. The xss lesson covers the general defense; here is how it applies to markdown comments.

Render on the server, sanitize with an allowlist

The naive pipeline converts markdown to HTML with a library and puts the result into the page with dangerouslySetInnerHTML. Some popular markdown libraries pass raw HTML through by default, since raw HTML is part of the CommonMark spec, so a comment containing an img tag with an onerror handler runs script. Even with raw HTML off, markdown links allow javascript: URLs in some configurations.

  • Convert markdown to HTML on the server, once, when the comment is saved or edited, with raw HTML disabled.
  • Run the output through an allowlist sanitizer (for example DOMPurify, which also runs on the server with a DOM implementation): only p, em, strong, a, code, pre, blockquote, ul, ol, li, and only the href attribute on links, with only https: and mailto: schemes. Everything else is dropped.
  • Store and send the sanitized HTML. The client inserts it as is. Readers never run a markdown parser, and the attack surface is one server function that is easy to test.
  • Add a Content Security Policy that forbids inline script, so a sanitizer bug still doesn't run code. Defense in depth.
  • For the composer preview, run the same parser and sanitizer config on the client. Optimistic comments render through this same path until the server's HTML replaces them.

Every link in a comment gets rel="ugc nofollow noopener", added by the sanitizer step, not trusted from input. ugc and nofollow tell search engines this is user-generated content we don't vouch for, which removes the reason to spam comments with links for ranking. Show the real destination domain next to links or on hover, since link text can lie ("bank.com" pointing somewhere else). Mentions render as links to profiles that the server resolved, not to whatever URL the text contained.

Moderation in the client

Moderation decisions are made on the server and by people, but the client shapes how much bad content readers see and how easy it is to report it.

  • Report: a menu item that opens a small dialog with reasons (spam, harassment, misinformation, other). Optimistically show "Reported" and offer to hide the comment for this reader right away.
  • Low-score collapse: comments below a score threshold arrive with state: "collapsed_low_score" and render collapsed ("comment score below threshold"), one click to open, as Reddit does.
  • Removed: the server sends a placeholder with no body; the client shows "[removed by moderator]" and keeps the children, exactly like soft delete.
  • Blocked users: the reader's block list collapses those authors' comments client-side, with the option to show.
  • Shadow states: some sites show a spammer's comment only to the spammer. That works only because the server decides per viewer; the client just renders what it gets.

Check your understanding · Exercise 11

Which measures stop a comment from running script in other readers' browsers? Select all that apply.

Accessibility, performance, and SEO

Tree semantics a screen reader understands

A sighted reader sees nesting through indentation and lines; a screen reader user only gets that structure if it is in the markup. It is tempting to reach for role="tree", since the data is a tree, but the APG tree view pattern describes a navigation widget like a file explorer, where each item is a short label and arrow keys move between items.

A comment holds paragraphs, links, and buttons. Inside a tree, screen readers usually switch to focus mode and may read each item as one block, so that content becomes hard to reach. The aria-patterns lesson explains why a role is a promise about keyboard behavior.

Decision Which markup should carry the comment tree for assistive technology?

Nested lists of article elements

Each comment is an article with a heading-like header (author and time) inside an li; replies are a nested ul inside it.

Collapse buttons use aria-expanded and aria-controls pointing at the reply list.

Pros

  • Screen readers announce list nesting levels and item counts ("list, 3 items, level 2") on their own.
  • Content stays fully readable and every link and button is reachable.
  • Works without JavaScript after SSR.

Cons

  • Level announcements vary between screen readers and browsers; it is helpful, not guaranteed.

role="tree" with treeitems

The ARIA tree view pattern: one tab stop, arrow keys move, Right expands, Left collapses.

Pros

  • Familiar keyboard model for file trees.

Cons

  • Treeitems are meant to be short labels, not articles with links and buttons inside.
  • Reading content in browse mode becomes unreliable, and we must build all keyboard handling ourselves.

Flat rows with aria-level

Virtualized rows each carry aria-level, aria-setsize, and aria-posinset on a role that supports them, such as listitem.

Pros

  • Works with virtualization, where nesting in the DOM is gone.

Cons

  • Needs a role that supports those attributes; support for reading them varies. Only worth it when virtualizing.

What we pick: Nested lists of article elements by default, which also matches our default nested rendering. Give each comment's header an accessible name like "Maya, 3 hours ago, 42 points, reply to Sam". Treat screen reader behavior as something to test with real tools (NVDA, JAWS, VoiceOver), not something the markup guarantees.

Keyboard: Tab already reaches every button and link, but in a 500 comment thread that is thousands of tab stops. Add shortcuts the way many forums do, listed in a help dialog: j and k move to the next and previous comment (focus the article, which has tabindex="-1"), Shift+J jumps to the next sibling, p goes to the parent, Enter toggles collapse, and r opens the reply box.

Single-key shortcuts must not fire while typing in a field, and WCAG's success criterion 2.1.4 asks that they can be turned off or remapped, or work only while the thread has focus. The keyboard-navigation lesson covers roving focus and shortcut rules.

Focus and announcements: after posting, keep focus on the new comment (or return it to the Reply button) and announce "Reply posted" through a polite live region; on failure, announce the error and focus Retry. After collapsing via the thread line, focus the collapse toggle in the comment's header, so focus never lands on an element that just disappeared. Respect prefers-reduced-motion for the highlight and insert animations.

Markup outline of the thread. A list for the thread, level 1, holds a list item with an article. The article has a header naming maya, 3 hours ago, 42 points, a body, and a Collapse button with aria-expanded true that names 14 replies. Inside the article, a nested list for replies, level 2, holds another list item with an article by sam with the same shape. Notes say lists announce their level and size, an article marks where one comment starts and ends, and the j, k and p keys move focus between articles.ul: thread, level 1li, articlemaya, 3 hours ago, 42 pointsCollapse, 14 repliesaria-expandedul: replies, level 2li, article: samsame shape, one level inlists announcelevel and sizearticle: wherea comment startsand endsj, k, p movefocus betweenarticles
No role="tree": plain lists and articles keep every link and button readable, and their nesting carries the reply structure. The collapse button says what it hides.

Performance numbers to hold

  • Load after the article: comments are below the fold for most readers. Server-render the first page into the HTML, but defer the comments JavaScript until the section is near the viewport (IntersectionObserver with a rootMargin of about one screen) or the browser is idle.
  • Reserve space: if comments are rendered on the client for some pages, reserve height for the section with a skeleton so the footer doesn't jump, keeping CLS under 0.1.
  • Cheap rows: memoize CommentNode by id; use content-visibility: auto with contain-intrinsic-size on top-level comment blocks so the browser skips layout and paint for those far off screen. This alone often handles 1,000 to 2,000 loaded comments without full virtualization.
  • Small payloads: a full first page (at most 140 comments) is about 84 KB of JSON, around 15 KB compressed. Avatars at 32 px via srcset, loading="lazy", with fixed width and height.
  • Measure: track INP on vote and collapse, time to show the first comment, post success rate, and live-channel reconnects in real-user monitoring.

SEO for comment content

Comments add fresh, long-tail text to an article, and readers search for discussions. Search engines can run JavaScript, but rendering is deferred and not guaranteed for every page, so the reliable path is the first page of comments in the server HTML, with rel="ugc" on its links. Permalink pages have their own canonical URL and title. Later pages behind "load more" don't need to be indexed; they are a small share of reading.

Check your understanding · Exercise 12

A screen reader user opens a thread. Which choices help them? Select all that apply.

Running it in the interview

In 45 minutes you can't cover everything in this guide. The interviewer is checking three things: do you see that the tree is lopsided and plan loading around that, do you model data so updates are cheap, and do you handle the moments where people act without losing their work or moving their page. Spend your time there and mention the rest briefly.

MinutesWhat to doWhat to draw or say
0 to 5Clarify depth, size, sorts, live updates, permalinks, SEO."How deep can replies go, and how big does a big thread get?" Write the numbers down.
5 to 10State what makes it hard; sketch the UI.The wireframe: comment header, body, actions, thread lines, "load more", the pill.
10 to 20Data model and loading: flat list with parentId, normalized store, O(n) build, bounded pages with per-parent cursors.Draw the flat list beside the tree; write the page budget (20 x 7 = 140).
20 to 30Architecture and API: store, API client, live channel, components; the endpoints.The component diagram with arrows labelled by what crosses them.
30 to 40Deep dive on whatever the interviewer picks: optimistic reply, rendering at scale, or live updates.A sequence diagram with the failure path.
40 to 45Safety, accessibility, performance in one minute each.Sanitize on the server, rel="ugc", nested lists of articles, SSR first page.

Say the trade-offs out loud as you go. "I'm choosing a flat list over nested JSON because every update becomes a lookup by id" earns more than drawing the right box silently. When you give a number, show where it came from: "600 bytes a comment times 20,000 is 12 MB, which is why we can't load it all".

Follow-up questions to expect

Think first

"A celebrity's post gets 200,000 comments in an hour. What breaks first on the frontend?"

Think first

"The reader writes a reply on the subway with no signal. What happens?"

Think first

"Two tabs: the author edits a comment in one and deletes it in the other. What does each tab show?"

Check your understanding · Exercise 13

You have 10 minutes left and the interviewer says "go deeper on anything". Which deep dives show the most senior judgment for this problem? Select all that apply.

Summary

  • A comment thread is a lopsided tree. Send it as a flat list with parentId, depth, and the ancestor path, and keep it in a normalized store: comments by id, plus a page of child ids per parent. Build it in one O(n) pass.
  • Never fetch everything. Load a bounded tree (20 top-level comments, 3 replies each, 1 below that), page each parent with its own cursor, show direct and total reply counts, and cut deep branches with "Continue this thread".
  • The server sorts; the client keeps that order stable. Best uses the lower bound of the Wilson score interval, which is fair to new comments. Scores update in place, positions only when the reader asks.
  • Render nested article elements in lists by default; collapse with clickable thread lines and remember collapsed ids per thread. Flatten to rows and virtualize only past a size threshold, measuring variable heights.
  • Post replies optimistically with a temporary client id that doubles as the idempotency key. Reconcile by that id with whichever arrives first, the response or the live event, and on failure keep the text with Retry and Edit.
  • Soft delete keeps "[deleted]" placeholders so replies keep their context. Votes PUT the final value and coalesce fast clicks.
  • Push changes with Server-Sent Events, but never move the page under the reader: new comments wait behind a "3 new comments" pill.
  • Sanitize on the server with an allowlist, add CSP as a second layer, mark links rel="ugc nofollow", and server-render the first page for speed and search engines.

Check your answer

Tick what your own design covered. Pick the level you're interviewing for: each level includes the ones before it.

Junior 0 of 4
Mid-level 0 of 5

References

Concepts behind it

Try next