Scale Interview
System DesignMedium

Design Reddit

Forum & Comments System Design

Overview

Reddit is a forum: users create posts inside subreddits (topic communities), reply to posts with comments that nest arbitrarily deep, vote posts and comments up or down, and read ranked listings — a subreddit's "hot" front page, the "top" of the week, the "new" queue, and a personalized home feed stitched from the communities they subscribe to. The product looks like a CRUD app with a comment box, but two things make it a real system design problem: the comment tree (a post can have tens of thousands of nested replies, and you can never load them all at once) and voting-driven ranking (every listing is sorted by a score that decays with time, and votes must be counted exactly once per user even under retries).

It is labeled "Medium" because the pieces are individually well-understood, but two of them have a sharp trap. The comment tree tempts you to model it as a table with a parent_id and then "just fetch the whole tree" — which is a recursive query that blows up on a viral thread. And voting tempts you to increment a counter on every click — which double-counts on a retry and melts under a hot post. Get the tree traversal and the idempotent vote right, and the rest is read-heavy caching you have seen before.

In this walkthrough you'll scope the system, size it, model the data, design the API, draw the write / vote / read paths, then go deep on the three things that decide the design: how to store and lazily load the nested comment tree, how votes turn into a time-decayed ranking that's cheap to serve, and how to serve the read-heavy subreddit and home listings (reusing feed and sorted-set machinery you've already seen).

Out of scope (say so): authentication, moderation tooling and spam/abuse enforcement beyond vote dedup, full-text search over posts (a separate search problem), private messaging/chat, awards, and media hosting (assume a blob store + CDN for images). We own posts, the comment tree, votes, and the ranked listings over them.

Functional requirements

  • Create a post in a subreddit (a title plus a link or text body).
  • Comment on a post, and reply to a comment — comments nest arbitrarily deep, forming a tree per post.
  • View a post with its comment tree — ranked (best-first), and lazily loaded: you get the top slice, then "load more replies" and "continue this thread" fetch more on demand.
  • Vote on a post or a comment (up or down). A user's vote counts once and is changeable (up → down → none).
  • View a subreddit listing sorted by hot, top (over a time window), or new.
  • View a home feed — a merged, ranked listing across the subreddits the user subscribes to.

Out of scope (state it): sign-up/auth, moderation and ban tooling, search, chat, awards/coins, and media transcoding. This system turns posts, comments, and votes into ranked, lazily-paginated reading surfaces.

Non-functional requirements

  • Read-heavy. Views vastly outnumber posts, comments, and votes; the same hot listings and hot threads are read constantly. Reads must be cheap and cached.
  • Vote counts are exactly-once per user, but eventually consistent in aggregate. A user's own vote must register once and be changeable, but the displayed score can lag a second or two — nobody notices whether a post is at 4,201 or 4,204. Eventual consistency for scores and rankings is fine.
  • Low read latency. A subreddit page or a comment thread's first screen should load fast (well under ~300 ms p99), even for a thread with tens of thousands of comments — which means never loading the whole tree.
  • Availability over strict consistency. A stale score or a comment that appears a second late is fine; a page that fails to load is not.
  • Durable content. Posts, comments, and each user's vote are the source of truth and must be durable. Scores and sorted listings are derived and rebuildable.

Estimations

State assumptions; the goal is to justify the tree traversal, the precomputed scores, and the listing caches — not exact arithmetic.

Rounded assumptions

  • DAU: ~50M (hundreds of millions registered).
  • Posts/sec. Say ~5M posts/day ÷ 86,400 ≈ ~60 posts/sec average, a few × at peak.
  • Comments/sec. Comments run ~10× posts: ~50M/day ÷ 86,400 ≈ ~600 comments/sec.
  • Votes/sec. Voting is the dominant write. ~50M DAU × ~10 votes ≈ 500M/day ÷ 86,400 ≈ ~6K votes/sec, with peaks several × higher on a viral thread.
  • Reads/sec. Page views dominate everything: ~50M DAU × ~30 views ≈ 1.5B/day ÷ 86,400 ≈ ~17K reads/sec average, call it ~40K/sec at peak. Reads outnumber all writes combined by ~2.5–3× on average (writes total ~6.7K/sec) — and the gap widens at peak, when hot listings and threads are hit hardest.
  • Comment-tree size. A normal post has a handful of comments; a viral one has tens of thousands, nested many levels deep. This single fact — a tree far too big to ship in one response — drives the whole comment design.
  • Listing hotness. A few default/huge subreddits (r/funny, r/AskReddit) and their front pages are read orders of magnitude more than the long tail — a hot-key concern.

How the numbers affect the design

Signal from the numbers Design decision
Reads ≫ writes, same hot pages Serve listings and hot threads from caches / precomputed sorted lists; never rank per read.
A viral post has tens of thousands of nested comments Never load the whole tree — store the tree so a bounded slice (top-level + first replies) is one cheap query, and lazy-load deeper.
~6K votes/sec, bursty on hot posts Votes are idempotent upserts keyed by user+target; scores update asynchronously, not a synchronous counter per click.
Every listing is sorted by a decaying score Precompute a hot_score on write (fixed time term) and keep a sorted list per subreddit; don't recompute all posts as time passes.
A few subreddits dominate reads Cache and replicate the hot listings so one popular front page isn't a single hot key.

Caution

Do not plan to load a post's entire comment tree per view, or to compute a listing's ranking by scanning and sorting all its posts per read. Both are fine for a toy forum and impossible at Reddit scale — the whole design exists to bound them to a small slice served from a precomputed structure.

Core entities / data model

Five things are stored. Posts, comments, and votes are the durable source of truth; scores and the sorted listings are derived and rebuildable from votes.

Entity Key fields
Subreddit subreddit_id (PK), name, created_at, subscriber count
Post post_id (time-sortable, e.g. Snowflake), subreddit_id, author_id, title, url_or_body, created_at, score, hot_score
Comment comment_id, post_id, parent_id (null for top-level), author_id, body, created_at, score, best_score, and a path (deep dive 1)
Vote (user_id, target_id)value ∈ {−1, 0, +1}, target_type (post/comment), updated_at — one row per user per target
Subscription (user_id, subreddit_id) — which communities a user follows, for the home feed

The Vote row is keyed by (user_id, target_id) — that composite key is what makes a vote idempotent and changeable: a second vote from the same user on the same target updates the existing row, it never inserts a duplicate. The stored score/hot_score/best_score are derived from votes and updated asynchronously (deep dive 2); if lost, they are recomputed by re-aggregating the durable Vote rows.

  • Posts and comments live in a sharded store partitioned by post_id — a comment tree is always read in the context of one post, so co-locating a post's comments on the same shard keeps tree reads local.
  • Votes live in their own store keyed by (user_id, target_id), so "has this user voted on this?" and "change my vote" are point operations.
  • Listings (per subreddit, per sort) are derived sorted sets in an in-memory cache (deep dive 3).

Note

A comment stores both its parent_id (its immediate parent) and a materialized path (its full ancestry). The parent_id alone models the edges; the path is what makes "give me this whole subtree" one indexed range read instead of a recursive walk (deep dive 1).

API design

Six operations carry the product: create a post, create a comment, read a listing, read the home feed, read a post's comment tree (lazily), and vote.

Create a post

POST /api/r/{subreddit}/posts
Body: { "title": "...", "url_or_body": "..." }
201 Created
Returns: { "post_id": "...", "created_at": "..." }

Store the post once, then add its post_id to the subreddit's sorted listings (new/hot). The author's implicit self-upvote seeds the score.

Create a comment (or reply)

POST /api/posts/{post_id}/comments
Body: { "parent_id": "c_88" | null, "body": "..." }   // null → top-level
201 Created
Returns: { "comment_id": "...", "path": "...", "created_at": "..." }

Insert the comment under parent_id (or as a top-level comment when null). The server computes its path from the parent's path (deep dive 1) and returns it so the client can slot the new reply into the tree it already has.

Read a subreddit listing

GET /api/r/{subreddit}?sort=hot|top|new&t=day|week|all&cursor=&limit=25
200 OK
Returns: { "posts": [ { post... }, ... ], "next_cursor": "..." }

Return a page of the subreddit's posts in the requested order — served from the precomputed sorted listing (deep dive 3). t is the time window for top. Pagination is cursor-based (the cursor encodes the last item's sort key + id), not offset — offset pagination shifts and duplicates as votes reorder the list underneath the reader.

Read the home feed

GET /api/home?sort=hot&cursor=&limit=25
200 OK
Returns: { "posts": [ ... ], "next_cursor": "..." }

Return a merged, ranked listing across the caller's subscribed subreddits (deep dive 3).

Read a post's comment tree (lazy)

GET /api/posts/{post_id}/comments?sort=best&parent=&after=&limit=50&depth=8
200 OK
Returns: {
  "comments": [ { "comment_id": "...", "parent_id": "...", "depth": 2, "body": "...", "score": 42,
                  "reply_count": 130, "more": { "after": "...", "remaining": 118 } }, ... ]
}

Return a bounded slice of the tree, not the whole tree. With no parent, it returns top-level comments (ranked by best) each with their first few replies down to depth; more markers tell the client where a "load more replies" (same parent, next page via after) or "continue this thread" (a deeper parent) call is available. This shape is the design — it's covered in deep dive 1.

Vote

PUT /api/votes
Body: { "target_type": "post"|"comment", "target_id": "...", "value": 1 | -1 | 0 }
200 OK
Returns: { "target_id": "...", "score": 4203 }   // possibly slightly stale

Cast, change, or clear a vote. It is a PUT (an idempotent upsert of the (user_id, target_id) row), not a POST that appends — re-sending the same vote is a no-op, so a retried request can't double-count (deep dive 2). value: 0 clears the vote.

Tip

Making vote a PUT on (user, target) rather than a POST /upvote is a small choice with a big payoff: idempotency, changeability, and dedup all fall out of the key. Interviewers notice when the API shape itself prevents double-counting.

High-level architecture

The system has a write path (create post/comment), a vote path (idempotent vote → async score/ranking update), and a read path (listings and comment trees from caches). We'll walk each, then combine.

1. Write path (create post / comment)

A new post or comment is stored once in the durable content store; a post additionally seeds the subreddit's sorted listings.

flowchart LR
    Client["Client"] -->|"POST post / comment"| WriteSvc["Content Service"]
    WriteSvc -->|"insert post (with derived path for comments)"| ContentDB[("Content Store<br/>posts + comments, durable")]
    WriteSvc -.->|"post_id + seed score"| Listings[("Subreddit listings<br/>sorted sets, in-memory")]
    classDef good fill:#dcfce7,stroke:#16a34a,color:#14532d;
    class ContentDB good
  1. The Content Service writes the post or comment to the durable store. For a comment, it computes the path from the parent and stores it alongside parent_id (deep dive 1).
  2. For a post, it adds post_id to the subreddit's new and hot sorted lists with a seed score (the author's self-upvote). Comments don't touch listings — they update the parent post's comment count and their own subtree.

2. Vote path (the dominant write)

A vote is an idempotent upsert; the aggregate score and the sorted listings update asynchronously off that write.

flowchart LR
    Client["Client"] -->|"PUT vote (user, target, value)"| VoteSvc["Vote Service"]
    VoteSvc -->|"upsert (user_id, target_id) → value"| VoteDB[("Vote Store<br/>durable, one row per user+target")]
    VoteDB -.->|"delta captured (old→new value)"| Stream[["Vote event stream"]]
    Stream -->|"apply delta (retried)"| Score[("Aggregate score + hot_score<br/>on the post/comment")]
    Score -.->|"reorder"| Listings[("Subreddit listings<br/>sorted sets")]
    classDef good fill:#dcfce7,stroke:#16a34a,color:#14532d;
    class VoteDB good
  1. The Vote Service upserts the (user_id, target_id) row. It reads the user's prior value, writes the new one, and computes the delta (e.g. up→down is −2). This upsert is the exactly-once guard: a retry finds the row already at the new value and produces a zero delta.
  2. The delta is captured off that durable write (outbox/CDC/stream) and a consumer applies it to the target's aggregate scoreretried until applied, so the count converges without the request handler blocking on it.
  3. Score changes recompute the target's hot_score/best_score and reorder the affected sorted listing (deep dive 2 and 3).

3. Read path (listings + comment tree)

Reads are the common case and are served from precomputed listings and cached comment slices.

flowchart LR
    Client["Client"] -->|"GET listing / home"| ReadSvc["Read Service"]
    ReadSvc -->|"range read (top of sorted list)"| Listings[("Listings cache<br/>sorted sets")]
    Client -->|"GET comment tree slice"| ReadSvc
    ReadSvc -->|"range read by path prefix"| ContentDB[("Content Store<br/>posts + comments")]
    ReadSvc -->|"hydrate bodies + scores"| ContentDB
    ReadSvc -->|"page + more markers"| Client
  1. Listings and the home feed are served by a range read over the precomputed sorted lists — cheap, and the hot ones are cached and replicated (deep dive 3).
  2. A comment-tree request reads a bounded slice — the top-level comments (or a subtree by path prefix) plus first replies — never the whole tree, and returns more markers for lazy loading (deep dive 1).

4. Combined architecture

flowchart LR
    Client["Client"] --> GW["API Gateway"]
    GW -->|"POST post/comment"| WriteSvc["Content Service"]
    WriteSvc --> ContentDB[("Content Store<br/>durable")]
    GW -->|"PUT vote"| VoteSvc["Vote Service"]
    VoteSvc --> VoteDB[("Vote Store<br/>durable")]
    VoteDB -.->|"delta stream"| Worker["Score Worker"]
    Worker -->|"update score / hot_score"| ContentDB
    Worker -->|"reorder"| Listings[("Listings cache<br/>sorted sets")]
    WriteSvc -.->|"seed new post"| Listings
    GW -->|"GET listing / home"| ReadSvc["Read Service"]
    ReadSvc --> Listings
    GW -->|"GET comment tree"| ReadSvc
    ReadSvc --> ContentDB
    classDef good fill:#dcfce7,stroke:#16a34a,color:#14532d;
    class ContentDB,VoteDB good

The Content and Vote stores are the source of truth; scores and listings are derived, kept converged by the score worker off the vote stream, and cached for the read-heavy path. The three paths scale independently — a voting storm on a viral thread loads the vote store and score worker without touching the listing reads.

Deep dives

1. The threaded comment tree — modeling and lazy loading

A post's comments form a tree: every comment has one parent (a comment or the post itself) and any number of children, nested arbitrarily deep. Two questions decide the design: how to store the tree so you can read a subtree cheaply, and how to avoid ever loading the whole tree. The second is the real trap — a viral post has tens of thousands of comments, so "fetch the comment tree" can never mean the whole thing.

Storing the tree. Three standard models, worst to best for this read pattern.

Adjacency list only — each comment stores parent_id, walk the tree with recursive queries (avoid as the whole answer — N+1 / recursive blowup)

Store comment(comment_id, parent_id, ...). To render a thread you start at the top-level comments and repeatedly fetch children of each — either one query per level per node (N+1 queries) or a single recursive CTE that walks the whole hierarchy.

render thread for a viral post:
  top-level comments: 2,000
  each with replies, nested up to 15 deep, 40,000 comments total
  → recursive CTE walks ALL 40,000 rows to materialize the tree
  → or N+1: thousands of round trips
  → then you ship 40,000 comments the user will never scroll to
  • Pro: trivial to model; fetching the direct children of one parent is a single indexed query (WHERE parent_id = ?) — which is genuinely useful for lazy loading.
  • Con: reconstructing an arbitrary subtree (parent + all descendants) means a recursive walk whose cost is the subtree size; there's no single range read for "everything under this comment." A whole-tree render is a recursive scan of every comment.
  • Verdict: don't rely on it to load subtrees. Keep parent_id (it's how you find direct children for "load more replies"), but pair it with a path so a subtree is one range read.
Closure table — a separate row for every ancestor→descendant pair (works, storage-heavy)

Keep a side table ancestor(ancestor_id, descendant_id, depth) with a row for every ancestor-descendant pair. A subtree is SELECT descendant_id WHERE ancestor_id = ?; the ancestors of a comment are WHERE descendant_id = ? — both single indexed reads, no recursion.

  • Pro: subtree and ancestor-chain queries are each one indexed read; depth is stored explicitly; no path-length limit.
  • Con: inserting a comment at depth d writes d+1 closure rows, and a deep, wide tree stores O(nodes × average-depth) pairs — a large multiplier on an already huge comment corpus. More write amplification per comment than any other model.
  • Verdict: great when you need fast ancestor queries too, but the storage blow-up is a lot to pay for a forum that mostly reads top-down subtrees. A materialized path gets the subtree read without the extra table.
Materialized path — each comment stores its full ancestry as an ordered path; a subtree is a prefix range read (recommended)

Store on each comment a materialized path — its ancestry encoded as an ordered key, e.g. 0001/0007/0042 (root → child → this comment), or a compact byte-packed equivalent. A child's path is parent.path + "/" + child_id, computed once at insert.

top-level comment c7  → path "0007"
  reply c42           → path "0007/0042"
    reply c88         → path "0007/0042/0088"

load the subtree under c42:  range scan where path LIKE "0007/0042%"
  → ONE indexed range read returns the whole subtree, already in tree order
  (segments are fixed-width zero-padded, so "0042" can't prefix-match a sibling "0421")
load only direct children:   parent_id = c42  (the adjacency edge)
sort siblings by best_score, paginate by (parent, after-cursor)

Because the path sorts lexicographically in depth-first tree order, a subtree is a single prefix range read, and the rows come back already ordered for rendering. Keep parent_id alongside for the "direct children of X" query that powers "load more replies."

  • Pro: a subtree is one range read, in tree order, with no recursion; inserts write a single row (no closure fan-out); the path doubles as a stable sort/pagination key within the tree.
  • Con: the path grows with depth (cap or compress it for pathological nesting), and moving a subtree means rewriting descendants' paths — but comments never move, so that cost never comes due.
  • Verdict: the default for a comment tree. One row per insert, one range read per subtree, and lazy loading falls right out of the path + parent_id pair.

Lazy loading is the actual product. However you store it, the reader never receives the whole tree. The initial load returns:

  • Top-level comments, ranked by best (deep dive 2), paginated — say the first 50.
  • For each, its first few replies down to a depth cap (say 8 levels).
  • more markers wherever content was withheld: "load more replies" (more siblings under the same parent — a next-page read by parent_id + cursor) and "continue this thread" (nesting past the depth cap — a fresh subtree read rooted at that deep comment, via its path prefix).
flowchart TD
    Root["Post"] --> C1["top comment A (best)"]
    Root --> C2["top comment B"]
    Root --> More1["... 1,948 more top comments<br/>(load more)"]
    C1 --> R1["reply"]
    C1 --> R2["reply"]
    R2 --> D["...depth cap reached<br/>(continue this thread →)"]
    C1 --> More2["... 126 more replies<br/>(load more)"]
    classDef warn fill:#fef9c3,stroke:#ca8a04,color:#713f12;
    class More1,More2,D warn

Both controls map to a bounded query: "load more replies" is a page of one parent's children; "continue this thread" is a subtree range read by path prefix. Each returns another bounded slice with its own more markers. The reader pulls the tree in on demand, and the server never materializes more than a page at a time.

Warning

The classic mistake is treating "get the comments" as "get the tree." A viral thread's tree is tens of thousands of nodes — loading it eagerly is a huge query and a huge payload the reader will never scroll. Bound both dimensions: breadth (paginate siblings) and depth (cap nesting, then "continue this thread").

Note

Sort order interacts with paging. best/top need each sibling group ranked by a stored score, so paginate siblings by (parent_id, best_score, comment_id). new paginates by created_at. The materialized path gives you tree order; the sibling sort key gives you rank order within a parent — you need both.

2. Voting and the "hot" / "best" ranking

Every listing and every comment thread is ordered by a score derived from votes. Two things must be right: votes are counted exactly once per user and changeable, and the score is cheap to serve — precomputed, not recomputed per read. And the ranking must decay with time so fresh content rises.

Idempotent, changeable votes. A vote is not an increment — it's the state of one (user_id, target_id) row holding −1 | 0 | +1. Casting a vote is an upsert: read the old value, write the new, apply the delta to the aggregate.

Increment a counter on every upvote click (avoid — double-counts and can't be changed)

On each upvote, score += 1; on downvote, score -= 1.

user taps upvote → score += 1
network retry re-sends the same request → score += 1 AGAIN   (double-counted)
user changes to downvote → score -= 1, but the original +1 is still baked in
no per-user record → "have I voted?" is unanswerable, and dedup is impossible
  • Con: not idempotent (a retry double-counts), not changeable (no record of the user's prior vote to reverse), and on a hot post every click contends on one counter row. There's no way to enforce one-vote-per-user because nothing records who voted.
  • Verdict: avoid. The aggregate must be derived from per-user vote rows, not incremented blindly.
Upsert a per-user vote row, apply the delta to the aggregate asynchronously (recommended)

Store one row per (user_id, target_id) with the current value. To vote: upsert the row, compute delta = new_value − old_value, and apply delta to the target's aggregate score off the durable write. The read-old, write-new, emit-delta step must be one atomic transaction on that row — so two concurrent submissions from the same user serialize (each sees the other's committed value) and can't both read old = 0 and each emit +1.

first upvote:      old 0  → new +1  → delta +1
retry same vote:   old +1 → new +1  → delta  0   (idempotent — no double count)
change to downvote:old +1 → new −1  → delta −2
clear the vote:    old −1 → new  0  → delta +1
aggregate score converges from the durable per-user rows and is rebuildable
  • Pro: idempotent (a re-applied vote yields a zero delta), changeable, and one-vote-per-user is enforced by the key. The aggregate is derived and can be rebuilt by summing rows.
  • Con: two steps (row + aggregate) instead of one increment; the aggregate is eventually consistent. Both are fine here.
  • Verdict: the standard model. Idempotency, changeability, and dedup all come from keying the vote by user+target.

Precompute the score; don't recompute per read. Store score (and hot_score) on the post/comment and update it asynchronously when votes change (the vote path above). The alternative — SELECT SUM(value) FROM votes WHERE target_id = ? on every read — recounts a viral post's hundreds of thousands of votes per view. Precompute-on-write wins because reads heavily outnumber writes and the same posts are read constantly.

The time-decayed hot score. Reddit's insight is a score whose time term is fixed at creation, so ordering only changes when votes change — not as the clock ticks:

hot(ups, downs, created_at):
  s       = ups - downs
  order   = log10(max(|s|, 1))            # diminishing returns: 10 votes ≈ 1 unit, 100 ≈ 2
  sign    = +1 if s>0 else (-1 if s<0 else 0)
  seconds = created_at - epoch            # FIXED once, at post creation
  hot     = round(sign * order + seconds / 45000, 7)

Two properties make this cheap to serve. First, log10 gives diminishing returns — the 1000th upvote moves the score far less than the 10th, so a modest-but-fresh post can out-rank an old juggernaut. Second, because seconds is fixed at creation, a post's hot score only changes when its vote count changes — you never have to re-score every post as time passes; newer posts simply enter with a higher time baseline. That's what lets you keep a stable sorted listing and only touch the entries whose votes moved.

"Best" for comments (confidence, not raw score). Ranking comments by ups − downs unfairly favors old comments that accumulated votes and a comment with 200 up / 100 down over one with 10 up / 0 down. Reddit ranks comments by best — the lower bound of a Wilson score confidence interval on the up-vote proportion. Intuitively it asks "given this many votes, how confident are we this comment is good?" — so 10/0 beats 200/100, and a brand-new great comment isn't buried under vote-count inertia. Like hot_score, best_score is precomputed from the up/down counts and updated when they change.

Vote manipulation and dedup (high level). Because votes are keyed by user, one account votes once per target for free. Beyond that: rate-limit votes per account, require account age/karma to vote, detect brigading (coordinated bursts from many accounts on one target) via anomaly detection on the vote stream, and fuzz displayed counts slightly so scrapers can't reverse-engineer exact tallies. These are asynchronous, off the hot path — the durable per-user rows are the ground truth a fraud job re-scores from.

Tip

The two ideas to say out loud: votes are an idempotent upsert of a per-user row (so retries and changes are safe), and the hot score's time term is fixed at creation (so the listing only reorders on vote changes, never just because time passed).

3. Serving the read-heavy subreddit and home listings

Listings are the read-heavy surface: a subreddit's front page sorted by hot, top (over a window), or new, plus a personalized home feed merging subscribed subreddits. This is the part you've seen before — so reuse, don't reinvent.

A sorted list per (subreddit, sort). Keep each subreddit's listing as an in-memory sorted set: member = post_id, score = the sort key (hot_score for hot, created_at for new, window score for top). A page is a range read of the top of the list — the same rank/range mechanics covered in the gaming leaderboard breakdown (get top-N, paginate by rank), so we won't re-derive the sorted-set internals here. When a vote changes a post's hot_score, the score worker updates that one member's score and the list reorders in place — no re-sort.

  • new is just insertion order (by post_id/created_at) — cheapest of all.
  • hot is the hot_score-ordered set, updated on vote deltas.
  • top over a window (day/week/all) needs a score bounded to that window. Keep a per-window sorted set (top:week) that ages out old posts, or compute top by filtering created_at — the windowing is the only wrinkle, and it's the same "time-windowed board" idea from the leaderboard breakdown.

The home feed = fan-out across subscriptions. A user's home feed merges the subreddits they subscribe to. Most users subscribe to a modest number (tens), so the natural approach is fan-out-on-read: take the top slice of each subscribed subreddit's cached hot list and k-way merge them by score into a page — exactly the read-time merge from the news feed breakdown, so its fan-out-on-write vs on-read analysis applies directly here. Because each subreddit listing is already precomputed and cached, the merge is over a handful of short, ready-made lists — cheap enough to do per request without precomputing a per-user feed.

flowchart LR
    Req["GET /home (user subscribes to r/a, r/b, r/c)"] --> Merge["k-way merge by hot_score"]
    La[("r/a hot list<br/>top slice")] --> Merge
    Lb[("r/b hot list<br/>top slice")] --> Merge
    Lc[("r/c hot list<br/>top slice")] --> Merge
    Merge --> Page["home page (top 25)"]
    classDef good fill:#dcfce7,stroke:#16a34a,color:#14532d;
    class Merge good

Hot subreddits are a hot-key problem. A few huge/default subreddits are read orders of magnitude more than the rest — their hot listing is a single hot key. The fix is the same as any hot key: replicate that listing across cache replicas and spread reads, and cache the fully-rendered first page (with a short TTL of a second or two, since a front page barely changes moment to moment). The long tail of small subreddits is served directly from the sorted set — no special handling needed.

Tip

Frame the home feed as a fan-out problem and the subreddit listing as a sorted-set/leaderboard problem, and explicitly reuse those two breakdowns instead of re-deriving them. The Reddit-specific novelty is the comment tree and the vote-driven scoring (deep dives 1 and 2) — signal that you know which parts are new and which are borrowed.

Note

Listings are derived and rebuildable. If a sorted set is lost, rebuild it by scanning recent posts' hot_score in the subreddit. So the cache buys latency, not correctness — a cold or evicted listing is a slow read, never a wrong one.

Tradeoffs & bottlenecks

  • Comment-tree model. Materialized path makes a subtree one range read with a single row per insert, at the cost of a depth-bounded path; a closure table adds fast ancestor queries but O(depth) rows per comment; plain adjacency list is simplest but needs recursion for subtrees. Path fits a read-top-down forum best.
  • Eager vs lazy tree loading. Lazy loading (paginate siblings, cap depth, "continue this thread") bounds every query and payload; eager whole-tree loading is simpler but collapses on viral threads. Always lazy.
  • Precompute-on-write vs recompute-on-read for scores. Storing hot_score/best_score and updating on vote deltas makes reads cheap and the listing stable; recomputing per read recounts votes every view. Precompute wins because reads dominate — at the cost of eventually-consistent scores.
  • Idempotent upsert vs counter increment for votes. The per-user row gives exactly-once, changeable votes and free dedup, at the cost of a two-step (row + async aggregate) write; a raw counter is one step but double-counts and can't be changed.
  • Fresh vs cached listings. Caching and replicating hot front pages removes most read load at the cost of a second or two of staleness — fine, since a front page barely moves and scores are eventually consistent anyway.
  • Home feed fan-out-on-read vs precompute. Merging cached subreddit lists per request avoids maintaining a per-user feed and stays fresh, at the cost of a small k-way merge per read; precomputing per-user feeds (like a celebrity fan-out) is overkill when each user subscribes to only tens of subreddits.

Extensions if asked

Add only the extension that changes the design discussion:

  • Search over posts and comments. A full-text problem in its own right — see the post search breakdown (inverted index, sharding, ranking).
  • Moderation and spam. Removals, locks, and automod rules; a removed comment becomes a tombstone in the tree (kept as a node so replies still render, body hidden).
  • Awards / karma. Aggregate a user's received votes into a karma score — another derived counter off the vote stream.
  • Media hosting. Images/video go to a blob store + CDN; the post stores a pointer, same as any media system.
  • Real-time comment updates. Push new comments into an open thread via a websocket/streaming layer fed by the comment write stream, instead of requiring a refresh.

What interviewers look for & common mistakes

What interviewers usually reward:

  • Modeling the comment tree for a bounded read — a materialized path (kept alongside parent_id) so a subtree is one range read, and recognizing you must never load the whole tree.
  • Lazy loading as the product — paginating siblings, capping depth, and exposing "load more replies" / "continue this thread," each mapping to a bounded query.
  • Idempotent, changeable votes — a per-user (user, target) row upserted with a delta, giving exactly-once counting, changeability, and dedup for free.
  • A precomputed, time-decayed score — storing hot_score (time term fixed at creation, so the listing only reorders on vote changes) and ranking comments by a confidence-based best, rather than recomputing sums per read.
  • Reusing feed and sorted-set machinery — framing listings as leaderboard-style sorted sets and the home feed as fan-out, and caching/replicating hot subreddits as a hot-key problem.
  • Treating scores and listings as derived and rebuildable from durable content and vote rows.

Before you finish, do a quick mistake check:

  • Did you avoid loading a post's entire comment tree, bounding both breadth (paginate siblings) and depth (cap + "continue this thread")?
  • Did you store the tree so a subtree is a cheap read (materialized path), not a recursive scan?
  • Did you make votes idempotent and changeable via a per-user row + delta, instead of incrementing a counter?
  • Did you precompute a time-decayed hot_score (fixed time term) and a confidence-based best_score, rather than recomputing per read?
  • Did you serve listings from precomputed sorted sets and the home feed by merging cached subreddit lists?
  • Did you handle hot subreddits as a hot-key problem with caching and replication?
  • Did you use cursor pagination for listings and treat scores/listings as rebuildable derived data?

Practice this live

Run this exact question with our AI voice interviewer and get feedback.

Start the interview →