Engineering Atlas › System Design Interviews

Design a community discussion forum

20 min read · last reviewed 2026-10-17

Open in the interactive app: practice, quiz, mock interview →

In one minute

Requirements

Functional

Non-functional

Out of scope: search, chat, ads, notifications, the recommendation models behind the newer personalised feed, and media transcoding (see Design YouTube).

Back-of-envelope

Round assumed numbers for planning, not Reddit's published figures.

For calibration, Reddit's own figures in 2017 were about a million posts and 75 million votes a day (Neil Williams, QCon SF 2017), so the vote volume above is deliberately generous headroom, not a measurement.

The design takeaway: votes are the dominant write, reads dominate overall, and post and comment storage is small.

API

GET  /r/{sub}/{sort}?t=day&after={cursor}&limit=25
     -> { posts: [ { id, title, author, score, num_comments, created_at, my_vote } ], after }

GET  /home?after={cursor}&limit=25          (subscribed communities, merged)

POST /api/vote        { thing_id: "t3_abc", dir: 1 | 0 | -1 }
     -> 202 { thing_id, dir }                (idempotent upsert; dir 0 removes)

POST /api/submit      { sub, kind, title, body | url, client_token }
     -> 201 { id: "t3_abd" }

POST /api/comment     { parent_id: "t3_abd" | "t1_x9", body, client_token }
     -> 201 { id: "t1_x9z" }

GET  /comments/{post_id}?sort=best&depth=8&limit=200
     -> { post, comments: [ { id, parent_id, depth, score, body, replies_more: 12 } ] }

GET  /api/morechildren?post_id=..&children=t1_a,t1_b,t1_c

Every object has a type-prefixed ID (t3_ for posts, t1_ for comments), so a vote endpoint and a cache key work the same for both. client_token makes submission idempotent if the app retries on a flaky connection. dir: 0 is the "unvote", so a client can send the final state rather than a delta, which makes retries harmless.

Data model

posts, partitioned by post_id: post_id, sub_id, author_id, title, body_or_url, created_at, ups, downs, num_comments, removed. Point reads by ID dominate, so ID partitioning spreads load evenly. Counts are denormalised onto the row and updated by the vote consumer, not by the vote request.

comments, partitioned by post_id, clustered by comment_id: comment_id, post_id, parent_id, author_id, body, created_at, ups, downs. Every comment of one thread lives in one partition, so loading a thread is one range scan. parent_id is either the post or another comment.

comment_tree, keyed by post_id: a precomputed blob of parent_id -> [child_id ...] plus per-comment depth and sort keys. It is rebuilt incrementally by the comment consumer and is what the comment page reads.

votes, partitioned by user_id, primary key (user_id, thing_id): dir, voted_at. Partitioning by user makes "show my votes on this page" one partition read for 25 thing IDs, and makes the uniqueness check local. A second, async copy partitioned by thing_id serves audits and recounts.

listings, keyed by (sub_id, sort, window): an ordered list of up to 1,000 (post_id, sort_key) pairs. Held in a sorted set (Redis) with a durable copy in a wide-column store so a cache loss can be rebuilt without rescanning posts.

subscriptions, partitioned by user_id: (user_id, sub_id). A few hundred rows per user at most.

High-level design

flowchart LR
  U["Browser / app"] --> CDN["CDN + page cache"]
  CDN --> API["API gateway"]
  API --> PS["Post and comment service"]
  API --> VS["Vote service"]
  API --> LS["Listing service"]
  VS --> VDB[("Votes by user_id")]
  VS --> Q["Vote queue by thing_id"]
  Q --> VC["Vote consumers"]
  VC --> PDB[("Posts and comments")]
  VC --> LC[("Listings: sorted sets")]
  PS --> PDB
  PS --> Q
  LS --> LC
  LS --> PDB

Write path (a vote).

  1. The client sends dir for a thing. The vote service upserts (user_id, thing_id) -> dir and reads the previous value in the same statement.
  2. If the direction changed, it publishes {thing_id, old_dir, new_dir, user_id} to the vote queue, partitioned by thing_id, and returns 202. The client already shows the new arrow and an adjusted count locally.
  3. A vote consumer owns a set of thing partitions. It drops votes flagged by anti-abuse rules, applies the delta to ups and downs, recomputes hot, top and controversial keys for that post, and updates the post's position in each listing it belongs to.

Read path (a community listing).

  1. Logged-out requests for /r/{sub}/hot are served from the CDN, which keeps the rendered page for about 30 to 60 seconds.
  2. For a signed-in reader, the listing service reads the top 25 IDs after the cursor from the sorted set, hydrates them from a post cache in one multi-get, and filters removed posts.
  3. It fetches the reader's votes on those 25 IDs from the votes table (one partition) to render the arrows.

Read path (the home feed). The listing service loads the reader's subscriptions, reads the top 50 or so entries of each subscribed community's hot list, k-way merges them by hot score, and caches the merged page per user for about a minute.

Deep dives

1. Listings: precompute per community, merge per reader

Option one is a follower-style fan-out on write: push every new post into every subscriber's feed. A large default community has tens of millions of subscribers, so one post would mean tens of millions of inbox writes, and every vote would change the order in all of them. Option two is a SQL ORDER BY over the community's posts on every page view, which only works for small communities.

I choose option three: one precomputed sorted list per (community, sort, window), maintained incrementally by vote consumers, capped at about 1,000 entries. Every reader shares it, so cost scales with communities and votes, not readers. Hot only changes on a vote, so order is stable between votes. "New" is an append. "Top of the day" is a sorted set rebuilt from hourly buckets, the same short-bucket trick as Design Top K.

The home feed is a merge. A reader with 200 subscriptions would need 200 list reads, so cap the merge: take the top 50 communities by the reader's activity, read 50 entries from each, merge with a heap and cache the result. This is the pull side of the hybrid in Design FB News Feed, used for everything because every "author" is a community with a shared ranking.

2. Votes: idempotent upsert, async counting

The obvious design increments ups on the post row inside the vote request. At 70k votes per second, and with a viral post receiving thousands of votes a second, that single row becomes the hottest lock in the database. It also double counts on retries.

The chosen design splits the vote into a record and a count. The record is an upsert keyed by (user_id, thing_id), which makes the operation idempotent and gives the delta for free: going from +1 to -1 is one fewer up and one more down. The count is applied by a consumer that owns the thing's partition, so there is exactly one writer per post and no row contention. For a hot post, the consumer batches: it sums deltas for one second and writes once, turning 3,000 increments into one. See Distributed Counting and Hot Keys for the general shape.

Reddit's early architecture put voting on asynchronous queues for this reason, and it shows fuzzed counts, so the lag is invisible. Anti-abuse sits in the consumer: votes from brand-new accounts or rings that always vote together are recorded (the voter sees their arrow) but not counted, which makes manipulation hard to probe from outside.

3. Comment trees at 40,000 comments

Storage options: an adjacency list (parent_id per row), a materialised path (0001.0007.0003), or a closure table of every ancestor and descendant pair. Adjacency is cheapest to write but needs one query per level. A path gives one-scan subtrees but cannot sort siblings by a changing score. A closure table grows with depth squared.

I store adjacency rows partitioned by post_id (so the whole thread is one scan) and keep a precomputed tree blob per post: children lists, depth, and each comment's sort keys. Reddit's open-source code did the same with a stored tree per link. Rendering is then in memory: start at the root, sort each sibling group by the chosen key, walk depth first, stop at a depth limit (about 8) and a total count (about 200). Every cut point becomes a "load more comments" stub that carries the hidden child IDs, which /api/morechildren resolves later.

The default "best" sort uses the lower bound of the Wilson score interval on the upvote fraction, at 80% confidence (z = 1.2816) in Reddit's open-source code (r2 _sorts.pyx). The point, as Evan Miller argues, is to rank by how good a comment probably is given how few votes it has, not by its raw average or net score (Evan Miller). A comment at 1 up and 0 down has a lower bound near 0.38 at 80% confidence; one at 100 up and 10 down has a lower bound near 0.87, so the well-tested comment wins. Comment votes reuse the post vote queue and only update the blob's sort keys.

Down the stack

Layer 1The contract

  • Vote. vote(user, thing, dir) is idempotent. After it returns, the user's own reads show dir. Within a few seconds, the thing's counts reflect at most one vote per user. Votes on archived things are rejected.
  • Listing. Each page is read from one listing version. The after cursor is the last post ID seen, so paging continues after that post even if scores moved, with occasional repeats that the client drops.
  • Hot. For two posts with the same net score, the newer one ranks higher. A post needs ten times the net score to match one posted 12.5 hours later.
  • Comment page. Shows at most N comments and depth D, with every hidden subtree represented by a stub, so no comment is unreachable.

Layer 2The mechanism

The open-source r2 code computes hot as round(sign(s) * log10(max(|s|, 1)) + (t - 1134028003) / 45000, 7), where s = ups - downs and t is the post's creation time in epoch seconds. The constant 1134028003 is a fixed epoch in December 2005 and 45,000 seconds is 12.5 hours. Because the age term uses creation time, not the current time, a post's hot score is a pure function of its votes, which is why listings only need updates on votes.

Historically, Reddit stored objects in Postgres as "things" with a narrow table of core fields and a key-value "data" table of attributes, cached heavily in memcached, and deferred votes and listing updates through RabbitMQ queues. Listings began as cached lists of IDs in memcache; recomputing them with a fresh query was too expensive, so Reddit switched to updating them in place under a lock, storing the sort keys with each ID, and moved them to Cassandra. Its engineers describe the result as a persisted, denormalised index rather than a cache (Neil Williams, QCon SF 2017). Today's listing store would be Redis sorted sets: a skip list plus a hash, where ZADD updates a member's score in O(log N) (Redis docs) and ZRANGE key 0 24 REV reads a page in O(log N + 25). Older material uses ZREVRANGE, which has been deprecated since Redis 6.2 in favour of ZRANGE ... REV (Redis docs).

Layer 3The machine

  • Sorted set memory. In Redis, a sorted set of 1,000 entries exceeds the default zset-max-listpack-entries threshold of 128 (redis.conf) and becomes a skip list, costing roughly 80 to 100 bytes per member: about 100 KB per listing. A million active (community, sort, window) combinations need about 100 GB of RAM, a modest cluster.
  • Vote upsert. An INSERT ... ON CONFLICT DO UPDATE ... RETURNING on Postgres writes one heap tuple and one WAL record of roughly 100 bytes, and is guaranteed atomic even under high concurrency. Returning the previous dir in the same statement needs PostgreSQL 18, which added RETURNING OLD.dir (Postgres docs); on older versions, read the old row first in the same transaction and accept a rare race when one user sends two votes on the same thing at the same moment. Group commit batches many transactions into one fsync, so a single NVMe-backed primary can absorb tens of thousands of votes a second; sharding by user_id across 8 to 16 primaries gives headroom for 70k/s.
  • Comment rendering. A 40,000-comment tree blob is about 2 MB (IDs, parents, scores). Deserialising it and sorting 200 visible nodes takes about a millisecond of CPU, much cheaper than 40,000 row fetches at 0.2 ms each.

Why it's built this way

  1. Reads outnumber writes by about 100 to 1 and many readers see the same page, so share computed rankings and cache rendered pages.
  2. Sharing rankings means ranking by community, so the home feed must be a read-time merge of shared lists.
  3. Votes are the heaviest write and target a few hot rows, so separate the idempotent record from the count and give each post a single writer behind a queue.
  4. A single writer adds lag, so show users their own vote locally and fuzz public counts, which also blunts manipulation.
  5. Threads are deep and huge, so the tree shape is precomputed once and rendered with cut-offs instead of queried per level.

Failure modes

In the wild

Trade-offs

OptionStrengthsWeaknessesChoose when
Per-community precomputed listingShared by all readers, cheap readsNeeds merging for home feedsRanking is public and per community
Per-user fan-out on writeFast personalised readsHuge writes for big communitiesFollower graphs with bounded audiences
Synchronous counter updateExact counts instantlyRow contention, double counting on retryLow-traffic forums
Upsert record, async countIdempotent, one writer per postSeconds of lagHigh vote volume
Adjacency list plus tree blobCheap writes, fast renderBlob rebuilds on large threadsLarge, deep threads
Closure tableArbitrary subtree queries in SQLStorage grows with depthSmall trees with complex queries
Hot formulaExplainable, cheap, no clock re-rankNot personalisedCommunity front pages

Leadership lens

Check yourself

  1. Why is a per-community precomputed listing chosen over fan-out on write to every subscriber?

    Show answer

    A large community has tens of millions of subscribers, so fan-out would mean tens of millions of inbox writes per post, and every vote would reorder all of them. One shared sorted list per (community, sort, window) makes cost scale with communities and votes, not readers, and the home feed becomes a capped read-time merge.

  2. Why does incrementing ups on the post row inside the vote request break down, and what replaces it?

    Show answer

    At 70k votes per second a viral post's row becomes the hottest lock in the database, and retries double count. The vote is split into an idempotent upsert keyed by (user_id, thing_id) and an async count applied by a single consumer per thing partition, which batches deltas for hot posts.

  3. Vote consumers fall five minutes behind. What breaks and how do you recover?

    Show answer

    Counts and listings go stale but no votes are lost, because vote records are durable in the votes table. The listing service keeps serving the last version, you alert on consumer lag, and counts can be recomputed from scratch for any post.

  4. Why are posts archived after about six months?

    Show answer

    Votes are about 2B a day at 100 bytes, roughly 200 GB a day and 365 TB over five years, while posts and comments total only about 16 TB. Closing old posts to voting and moving their vote rows to cold storage is the largest storage lever.

Sources and further study

Primary

Systems docs

Books

Other curricula

Watch

Free resources

Open in the interactive app: practice, quiz, mock interview →