System design interview

Design nested comments with voting

Materialized paths, one vote per user, hot scores, thread caching and soft deletes.

At a glance

Level
Intermediate
Topics
Storage, Caching
Main parts
Comment API · Vote store · Thread cache · Comment DB · Vote queue · Score worker
What you'll learn
Practise this problem
Overview
ReaderComment APIVote storeThread cache10 s pagesComment DBtree, scoresVote queuevote changesScore workerbatches 5 sReaderComment APIVote storeone row per userThread cache10 s pagesComment DBtree, scoresVote queuevote changesScore workerbatches 5 s

Press Play to follow a request through the design, or step through it.

  • Client and edge
  • Service
  • Data store
  • Cache and queue
  1. The reader opens a post and asks for its comments sorted by hot.
  2. The API looks for the page under the post, the sort order and the page number.
  3. The cache has no page, so the lookup misses. On a hot thread it would usually hit here and skip the next steps.
  4. One query reads the top replies under each parent, down to a fixed depth, for this post.
  5. The database returns the rows. The API links them into a tree and orders siblings by hot score.
  6. The API stores the built page in the cache for 10 seconds.
  7. The reader gets the thread, with collapsed branches shown as 'load more replies' links.
  • Reader
  • Comment API
  • Vote store: one row per user
  • Thread cache: 10 s pages
  • Comment DB: tree, scores
  • Vote queue: vote changes
  • Score worker: batches 5 s
On this page

In 2 minutes

Key ideas
  • The numbers. Reads dominate, and most of them hit the same few hot threads, so a short-lived cache removes most of the load.
  • Tree storage. Materialized path.
  • Vote counters. Batch changes through a queue.
  • Ranking by time. A time bonus fixed at creation.
  • Caching a thread. A shared page for 10 seconds, with viewer votes added after.
Read the 2-minute answer

I would store comments in one table with a materialized path. Each row holds its post, parent, depth and a path made of fixed-width blocks, so a branch is a prefix range on an index and sorting by path gives depth-first order. To open a thread, the API checks a cache that holds each page for 10 seconds. On a miss, one query returns the top 5 replies under each parent down to depth 6, and the service builds the tree.

With a 90% hit rate, the database sees about 700 reads a second at peak. Votes go to a store with one row per user and comment. The upsert returns the old value, so the change is the new value minus the old one, and a double click is a zero. Changes flow through a queue to workers that sum them for 5 seconds per comment and write one update, turning 4,600 writes a second into about 600.

The hot score is a signed log2 of the score plus a time bonus fixed at creation, so it changes only on votes. Deletes are soft, so replies stay. The trade-off is that public counts lag by up to about 16 seconds: 5 in the batch, 1 to write and 10 in the cache.

Step 1 of 6: Clarify the scope

Questions to ask first

Ask these before you draw anything. Guess why each answer changes the design, then open it to check.

Is there a limit on how deep replies can go?
Why it matters: A depth limit bounds the size of a stored path and the cost of drawing a thread. With no limit, a long chain can break both.
Which sort orders do readers get: newest, best, hot, controversial?
Why it matters: Newest follows creation order. Best and hot depend on votes, so they need a score that changes and an index that follows it.
Can a user change or take back a vote?
Why it matters: A vote that can change must be stored per user, not just added to a counter, so the old value can be undone.
What should happen to the replies when a comment is deleted?
Why it matters: If replies must stay, a delete cannot remove the row. It has to hide the content and keep the place in the tree.
How fresh must counts and ordering be?
Why it matters: A few seconds of delay allows batching and caching. Instant counts for everyone would force a write for every vote.

Requirements

Functional

  • Post a comment on a post, or reply to any comment, to a bounded depth.
  • Show a thread as a tree, sorted by best, hot or newest, with long branches collapsed behind a load-more link.
  • Let a user upvote or downvote a comment once, and change or remove that vote.
  • Delete a comment without deleting the replies under it.

Non-functional

  • Open a thread: p99 under 200 ms for the first 200 comments.
  • A voter sees their own vote at once, and public counts and order catch up within 20 seconds: up to 5 in the score batch, 1 to write, 10 in the page cache.
  • A user never has more than one vote on a comment, even after a double click or a retry.
  • Handle 7,000 thread views a second and 4,600 votes a second at peak.

Step 2 of 6: Size it

Show the maths

These numbers are assumptions for the exercise, not facts about a real system:

  • The site gets 2 million new comments, 40 million votes and 60 million thread views a day, and peak is 10 times the average.
  • A thread view returns up to 200 comments. A comment row takes about 500 bytes with its path and indexes, and a vote row takes 16 bytes.
  • 90% of views land on a few thousand hot threads, so a cache that holds a page for 10 seconds answers 90% of views.
  • In a 5-second window at peak, the votes touch about 3,000 different comments.

The arithmetic:

Thread views per second
60M a day ÷ 86,400 s ≈ 694
≈ 700/s on average, 7,000/s at a 10x peak
Database reads with the cache
7,000/s × 10% misses
≈ 700/s, easy for one primary and a replica
Score writes with batching
Peak 4,600 votes/s × 5 s = 23,000 votes on ≈ 3,000 comments; 3,000 ÷ 5 s
≈ 600 updates/s, against 4,600/s unbatched
Comment storage
2M × 365 = 730M comments × 500 bytes
≈ 365 GB a year
Vote storage
40M × 16 bytes = 640 MB a day × 365
≈ 234 GB a year

Step 3 of 6: Sketch the path of one request

Step through the diagram at the top of the page to watch a request move through these parts.

A Reader talks to the Comment API. To open a thread, the API first looks in the Thread cache, which holds recently built pages for about 10 seconds. On a miss it runs one query against the Comment DB, which returns the first page of the tree, and then fills the cache.

A reply is one insert into the Comment DB. Each row stores its post, its parent and a path that encodes all its ancestors, so a whole branch can be read with one range scan.

A vote goes to the Vote store, which has one row per user and comment. That row is what makes a second click harmless. The API then publishes the change in the vote to the Vote queue. The Score worker adds up the changes for each comment over a few seconds, and writes the new score and hot score to the Comment DB in one update per comment.

Other paths through the system

Post a reply

  1. The reader writes a reply under a comment and submits it.
  2. The API reads the parent's path, adds the new comment's own segment, and inserts one row, unless the depth limit is reached.
  3. The database confirms the insert.
  4. The reader sees their reply at once, because the page adds it locally without waiting for the cache.

Vote on a comment

  1. The reader clicks the up arrow on a comment.
  2. The API writes the vote under the key of this user and this comment, and learns what the old value was.
  3. The store returns the old value, so the API can compute the change: from none to up is plus 1.
  4. The reader sees the arrow lit and the count nudged, without waiting for the public count.
  5. If the change is not zero, the API publishes it to the queue. A repeat click gives zero and sends nothing.
  6. The worker reads the queue and sums the changes for each comment over about 5 seconds.
  7. It writes one update per comment, adding the sum to the score and recomputing the hot score.

Step 4 of 6: Go deep on the hard part

Storing the tree

InterviewerHow do you store a tree of comments so that loading a page of a thread is one query?There are three common ways to store a tree in a table.

There are three common ways to store a tree in a table.

  • Adjacency list. Each row stores its parent id. Writes are trivial. Reading a branch needs a recursive query, or one query per level, and cost grows with depth.
  • Materialized path. Each row stores the path of its ancestors, such as the ids of every ancestor joined together. A branch is a prefix range on an index, and sorting by path gives depth-first order for free.
  • Closure table. A second table holds one row for every ancestor and descendant pair. Branch reads are simple joins, but a comment at depth 4 adds 5 rows. At 2 million comments a day, that is about 10 million extra rows a day.

Comments never move to another parent, which is the case where paths hurt most. So I would choose the materialized path. Give each comment a number within its post, taken from a counter on the post row. Write the number as a fixed-width block of 4 base-36 characters, which allows over 1.6 million comments in one post. A reply's path is its parent's path plus its own block. Fixed width matters: it makes plain string comparison put a parent before its children and siblings in creation order. A depth limit of 20 caps a path at 80 characters. A reply to a comment at the limit attaches to its parent instead, so a chain cannot grow forever.

Index on the post and the path together. Loading oldest-first is then a range read with a cursor on the last path seen. Best and hot orders are not path order, so use one query with a window function. It numbers the children under each parent by score and keeps the top 5, down to depth 6. The service drops any row whose parent did not come back, orders siblings by score, and walks the tree depth-first.

Each row also stores its parent, depth and a direct reply count. The count is what a 'load 12 more replies' link shows, and clicking it reads that branch by path prefix.

Votes, counts and the hot score

InterviewerHow do you count votes so a user votes once and a hot comment is not a bottleneck?One vote per user. The Vote store has a unique key on the comment and the user, and holds a value of minus 1, 0 or plus 1.

One vote per user. The Vote store has a unique key on the comment and the user, and holds a value of minus 1, 0 or plus 1. A vote is an upsert that returns the old value in the same statement. The change is the new value minus the old one. A double click or a retry finds that the value is already set, computes a change of zero, and stops. The same rule handles flipping from up to down, which is a change of minus 2.

Batching the counter. Updating the comment's score on every vote means one hot comment takes hundreds of writes a second on one row. Instead, the API sends each nonzero change to a queue partitioned by comment id. One worker owns each comment, sums its changes for about 5 seconds, and writes a single update. In our estimate that turns 4,600 updates a second into about 600. A comment with 1,000 votes a second becomes one write every 5 seconds. A queue may deliver a message twice, so the score can drift slightly. A job that recounts recently active comments from the vote rows corrects it.

Hot score. Sorting by raw score would keep old comments on top forever. Add a time bonus that is fixed when the comment is created:

  • hot = sign(score) × log2(max(|score|, 1)) + seconds since a fixed start date ÷ 43,200

The sign keeps a heavily downvoted comment below a neutral one: minus 8 scores minus 3, while scores of minus 1, 0 and 1 all tie at 0. Every 12 hours of age costs one doubling. A comment posted 12 hours later with half the score ties with the older one: 8 votes is 3, and 4 votes is 2, plus 1 for the newer 12 hours. Because the time part is a constant for a comment, the hot score changes only when its votes change. No job has to re-score every comment each minute. Store it in a column and index it by post and hot score.

For the best order, use a score that distrusts small samples, such as the lower bound of a confidence interval on the share of up votes. A comment with 1 up vote and no down votes should not beat one with 90 up and 10 down.

Finally, rate-limit votes per account, and give less weight to votes from brand-new accounts. Cheap vote farms are the main way this system is attacked.

Hot threads, collapsed branches and deletes

InterviewerOne thread is read by tens of thousands of people at once. How do you serve it, and how do you handle deletes and very deep branches?Cache the page. The key is the post, the sort order and the page number.

Cache the page. The key is the post, the sort order and the page number. The value is the built tree. A TTL of 10 seconds is enough. It adds at most 10 seconds to the 5-second batch and the write, which fits the 20-second target. With 90% of views on hot threads, the database sees about 700 reads a second at peak. Per-reader details are kept out of the cached page: it holds no votes of the viewer. The API reads that viewer's votes for just the 200 comment ids on screen, in one indexed lookup, and merges them in.

When a hot page expires, thousands of requests may miss at the same moment. Let one request rebuild it while the others wait or are given the slightly old copy. This is often called single flight, or serving stale while revalidating.

Collapse by default. The first page shows the top 5 replies under each parent, down to depth 6. Anything cut off is a stub with the number of replies it hides. Loading it is a range read on that branch's path prefix, with a cursor for the next page. A thread with 100,000 comments never has to travel in one response.

Soft deletes. Hard-deleting a comment would orphan its replies or cascade a delete through the branch. Instead, set a deleted flag, blank the text and author in the response, and keep the row, its path and its score. Readers see a placeholder, and the replies stay in place. For privacy, also erase the text and author from the stored row itself, so only the empty shell remains.

A deleted comment with no live replies is clutter. A background job can mark those as pruned and hide them. Doing that check on each read would be too costly. Moderator removals work the same way, with a reason stored for the audit log.

Step 5 of 6: Name the trade-offs

Tree storage
Chose
Materialized path
Over
Adjacency list or closure table

A branch is one index range and the order comes free. The cost is a depth limit and a costly rewrite if a subtree ever moves.

Vote counters
Chose
Batch changes through a queue
Over
Update the score on every vote

It cuts writes by about 8 times and removes hot rows. The cost is public counts that lag by up to about 16 seconds with the cache, and a small chance of drift.

Ranking by time
Chose
A time bonus fixed at creation
Over
Re-score every comment on a schedule

The hot score changes only when votes arrive, so there is no recompute job. The cost is a fixed 12-hour decay that is hard to tune per community.

Caching a thread
Chose
A shared page for 10 seconds, with viewer votes added after
Over
A cache entry per viewer

Everyone shares one entry, so the hit rate stays at about 90%. The cost is one extra lookup per view and up to 10 seconds of staleness.

Deleting
Chose
Soft delete that keeps the row
Over
Remove the row and its replies

Replies keep their context and nothing is orphaned. The cost is that rows stay, so privacy needs the text itself to be erased.

Step 6 of 6: Say what breaks next

Follow-up questions an interviewer asks once the design is on the board. Answer each one out loud, then open it to compare.

How do you handle a post with 100,000 comments?
Page by top-level comment and use a cursor on the sort key and id, not an offset. Show only a few replies under each one and load deeper branches on request. The path index keeps each of those reads small.
How would a moderator move a branch to another comment?
Rewrite the path prefix of every row in the branch in one update, using the old prefix as the range. The cost grows with the size of the branch, so it is acceptable for a rare moderator action. If moves were common, the adjacency list would suit better.
How would readers see new replies without refreshing?
Poll a cheap count of comments for the post every 20 seconds or so, and show a 'new replies' button instead of moving the page under the reader. The reader's own replies are inserted locally at once. A push connection is possible, but polling is simpler and enough for most threads.
How do you stop vote manipulation?
Limit votes per account and per address, and discount votes from new accounts or accounts that always vote together. Do the analysis in a batch job and adjust the stored weights, so the live path stays fast. Never show exact scores on very new comments, which makes testing a bot harder.

Free ATS check

See your resume the way an ATS reads it, then fix what it misses.

Drop in a PDF for a score out of 100, every fix that cost a point, and the line it belongs to. It runs in your browser, with no signup for the score.