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.
| Question | Answer we assume | What 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. |
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.
- 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.
- 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.
- 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.
- 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.
- 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?
The page that happens to contain that one comment becomes huge: 1,200 comments, about 720 KB of JSON, while the other pages are tiny. Response time, parse time, and render time all spike for that page only, which is the kind of bug that slips past testing on small threads.
A better contract caps the replies per comment and returns a count of the rest plus a cursor, so every page has a predictable size however lopsided the tree is. The loading chapter turns this into a page budget.
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?
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.
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.
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 };
}
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.
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
| State | Where | Why |
|---|---|---|
| Comments by id, child id lists, counts and cursors | Normalized 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 order | URL query (?sort=best) | Shareable and survives reload; changing it is a new query key. |
| Collapsed comment ids | Client store, saved to sessionStorage per thread | Survives scrolling away and coming back, and a reload, but is not worth syncing to the server. |
| Reply drafts per parent | Client store keyed by parent id, saved to localStorage | A half-written reply must survive collapsing the branch and an accidental reload. |
| My vote per comment | On each comment record (myVote) | Rendered with the comment; changed optimistically. |
| Highlighted comment | URL (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?
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 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.
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);
}
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
| Sort | Rule | Good for | Problem |
|---|---|---|---|
| New | Newest first, by creation time. | Following a live discussion. | Noise: the newest is rarely the best. |
| Top | Highest 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. |
| Controversial | Many votes, split close to half and half. | Finding arguments. | Meaningless on small numbers. |
| Best | The 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:
| Comment | Up / down | Top (up - down) | Best (Wilson lower bound) | Controversial |
|---|---|---|---|---|
| A | 2 / 0 | 2 | 0.55 | 0 |
| B | 90 / 10 | 80 | 0.85 | 1.7 |
| C | 600 / 400 | 200 | 0.58 | 100 |
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.
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?
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".
.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));
}
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.
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.
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;
}
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+EnterorCmd+Enterposts;Escapecloses 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?
Never silently drop it. Deleted comments in this design stay as "[deleted]" placeholders (see the editing chapter), and the server rejects new replies to removed comments with a clear error code.
Keep the draft, show the error near the box ("This comment was removed; you can't reply to it"), and offer to copy the text or post it as a top-level comment instead. The draft belongs to Maya, not to comment A.
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.
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.
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
}
}
clientId, never a new one.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?
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/votewith{ "value": 1 }is idempotent: sending it twice leaves the same result. APOST /upvotethat 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.
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?
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 adjustscrollTopby 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.
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.
Permalinks: opening one comment in context
People share links to single comments: "look at this reply". The link must show that comment even if it sits 12 levels deep inside a branch that the normal first page would never load, highlight it, and scroll to it.
The naive approach puts a fragment on the thread URL (/article/42#c-9f3) and lets the browser scroll to the element with that id. That only works if the comment is in the first page. Most shared comments are not; the browser finds nothing and stays at the top. Loading pages until we find it could take dozens of requests.
So permalinks get their own route, for example /article/42/comments/c_9f3, backed by an API call that returns a context slice: the comment, its full chain of ancestors up to the top level, its first few replies, and optionally one or two siblings. Because every comment carries its ancestor path, the server can fetch the ancestors in one query.
The client renders this slice as a normal tree: the ancestors with no siblings, the target, and its replies. A banner says "You are viewing a single comment's thread. View all comments", and each ancestor shows "N other replies" that load in place.
- Server-render it. The permalink page is what gets shared on social media and indexed, so the slice is in the HTML, with the target comment's text in the page title and description for link previews.
- Highlight with the URL. Give the target element
id="c-9f3"and include#c-9f3in the URL; the CSS:targetpseudo-class can then style it with no JavaScript. Addscroll-margin-topequal to the sticky header height so it doesn't hide under the header. - Scroll after render. If the slice is rendered on the client, the browser's own fragment scroll happens before the element exists. Call
element.scrollIntoView({ block: "center" })after the first render, then move focus to the comment (it hastabindex="-1") withfocus({ preventScroll: true }), so keyboard and screen reader users start there too without a second jump. - Deep targets. If the target is deeper than the visual depth limit, root the view at the deepest ancestor that fits, with "Continue this thread" links upward, rather than squeezing it into a 30 px column.
- Missing targets. If the comment was deleted, show the placeholder in context; if it was removed or never existed, say so and show the thread.
:target highlights it and focus moves to it; siblings load only when asked.Think first
Why not make the permalink just /article/42?focus=c_9f3 and load the normal first page plus the target?
You can, but the normal first page is 140 comments that mostly have nothing to do with the shared one, and the target still needs its ancestors to make sense. The reader who followed a link wants the conversation around that comment, fast.
A dedicated route also gives a distinct canonical URL for search engines and a distinct title and description for link previews, which a query parameter on the article URL tends to blur.
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.
| Part | Responsibility | Why the boundary is here |
|---|---|---|
CommentsSection | Entry 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. |
Composer | Textarea, 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 store | byId, child pages per parent, collapsed ids, drafts, pending temp ids. | One place where pages, live events, and optimistic writes merge and dedupe. |
| API client | Typed calls for pages, context slices, post, edit, delete, vote, report; idempotency keys; error mapping. | Keeps HTTP details and retries out of components. |
| Live channel | SSE 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 renderer | Same 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.
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.
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
};
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" }
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)
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 }
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.
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
};
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 thehrefattribute on links, with onlyhttps:andmailto: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?
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.
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 (
IntersectionObserverwith arootMarginof 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
CommentNodeby id; usecontent-visibility: autowithcontain-intrinsic-sizeon 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 fixedwidthandheight. - 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.
| Minutes | What to do | What to draw or say |
|---|---|---|
| 0 to 5 | Clarify 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 10 | State what makes it hard; sketch the UI. | The wireframe: comment header, body, actions, thread lines, "load more", the pill. |
| 10 to 20 | Data 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 30 | Architecture and API: store, API client, live channel, components; the endpoints. | The component diagram with arrows labelled by what crosses them. |
| 30 to 40 | Deep dive on whatever the interviewer picks: optimistic reply, rendering at scale, or live updates. | A sequence diagram with the failure path. |
| 40 to 45 | Safety, 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?"
The live channel and the reader's attention break first, not rendering: hundreds of new comments a minute arrive. Cap the pill ("99+ new"), push only counts per parent instead of comment bodies, let readers fetch on demand, and batch score updates further (every 30 seconds).
On the read side, the shared first page is the same for every logged-out reader, so CDN caching with a few seconds of max-age absorbs the load, and rendering stays bounded because pages are bounded.
Think first
"The reader writes a reply on the subway with no signal. What happens?"
The draft is already in localStorage, so nothing is lost. Posting creates the temporary comment, the request fails with a network error, and the comment shows as failed with Retry; when online fires, it retries once with the same idempotency key.
A full offline queue with a service worker and Background Sync is possible, but Background Sync is Chromium-only, so it can only be an enhancement on top of the retry-on-online path.
Think first
"Two tabs: the author edits a comment in one and deletes it in the other. What does each tab show?"
The delete wins on the server. The edit tab's PATCH gets 409 or 410 Gone; it keeps the text in the editor, says the comment was deleted, and offers to copy the text. The comment.deleted live event turns the comment into the placeholder in both tabs.
A BroadcastChannel between the author's tabs can share drafts and pending writes, but the live stream already covers correctness; it only makes the second tab update faster.
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
articleelements 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
PUTthe 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.
References
- How Not To Sort By Average Rating (Evan Miller) The Wilson score lower bound that Reddit's Best sort is based on, explained with examples.
- Reddit's open-source sort functions The actual confidence (Best) and controversy formulas, including the 80% confidence constant.
- Qualify your outbound links (Google Search Central) What
rel="ugc",nofollow, andsponsoredmean to search engines. - DOMPurify An allowlist HTML sanitizer that runs in browsers and on the server.
- EventSource (MDN) Server-Sent Events in the browser, reconnection, and the HTTP/1.1 connection limit.
- The Idempotency-Key HTTP header (IETF draft) How a client makes POST retries safe.
- Tree View pattern (WAI-ARIA APG) Read it to see why a comment thread is not this widget.
- :target (MDN) Styling the element named by the URL fragment, for permalink highlights.
- content-visibility (web.dev) Skipping layout and paint for off-screen content, a cheap step before virtualization.
- Understanding SC 2.1.4: Character Key Shortcuts (W3C) The rules for single-key shortcuts like j and k.
Concepts behind it
- Normalizing stateStoring each entity once, in a table keyed by its id, and having lists and relationships point at ids instead of holding copies, so one change updates every view that shows it.
- Pagination: offset, cursor, and keysetWays to load a long list one piece at a time, and why the choice decides whether users see duplicates, miss items, or wait on slow deep pages when the list changes.
- Optimistic updatesShowing the result of a user's action at once, before the server confirms it, and quietly undoing it if the server says no: the trick that makes likes, checkboxes, and chat messages feel instant on any network.
- List virtualizationRendering only the rows the user can see in a long list, so a list of 100,000 items costs about as much as a list of 30.
- Server-sent eventsOne HTTP response that never finishes: the server keeps writing small text events to it, and the browser's
EventSourceparses them, reconnects on its own, and resumes where it left off. The simplest push channel when data flows from server to client. - Cross-site scripting (XSS)Cross-site scripting is when text from outside your app ends up running as code inside your page, with every power your own code has: the most common and most damaging frontend security bug, and the one frontend engineers own most directly.
- ARIA widget patternsThe tested recipes for custom widgets from the ARIA Authoring Practices Guide: combobox, listbox, menu, tabs, dialog, tree, grid, disclosure, and accordion, with the roles, states, and keys each one promises, and how to pick the right one.
- Keyboard navigationMaking every action work without a mouse: a Tab order that follows the page, a focus ring you can always see, arrow keys inside composite widgets, skip links past repeated content, and no place where focus gets stuck.