Reddit is more than 100,000 active communities — subreddits — where people post links, text and images, vote them up or down, and argue in comment threads tens of thousands of replies deep; the company reports over 130 million daily active uniques (October 2026). Three things make it a good design problem. Almost every page is a sorted list — hot, new, top, controversial, best — and sorting millions of rows per read is out of the question. Votes are the dominant write: tiny, constant, and piled onto whatever is popular right now. And comments form trees: one India–Pakistan match thread can hold 50,000 comments while a few hundred thousand people refresh it. You will design the core — communities, posts, votes, comments, listings, a home feed and basic moderation.
Understand the problem
Reddit looks like a forum, but its engineering shape is a ranking system with a forum attached. Agree early on which sorted views you must serve, because each one becomes a structure you maintain, and on how fresh scores must be, because “seconds” rather than “instantly” is what makes batching possible.
Functional requirements
- Users should be able to create communities and submit posts — a link, a text post or an image — to a community.
- Users should be able to upvote or downvote any post or comment, change their mind, or take the vote back. Each user counts at most once per item.
- Users should be able to comment on a post and reply to any comment, to any depth, and read the thread sorted by best, top, new or controversial.
- Users should be able to browse a community sorted by hot, new, top (day, week, month, all time) or controversial, and a home feed built from the communities they joined.
- Moderators should be able to review reports and remove posts and comments, and a removal should vanish from every listing within seconds.
Non-functional requirements
- Scale. 130M daily users and 100K+ active communities, using Reddit’s published figures. Assume 2M posts, 10M comments and 300M votes a day.
- Fast reads. Listing and thread pages at p50 under 100 ms and p99 under 300 ms at the API, for about 90k reads/s at the daily peak.
- Votes feel instant. Your own vote is acknowledged in under 100 ms at p99 and shows at once. Everyone else sees updated scores and re-ordered listings within 5–10 seconds, and within a minute under heavy load.
- Availability and durability. 99.95% for reads. Writes may queue during an incident, but an acknowledged post, comment or vote is never lost.
- Consistency, chosen per piece. Eventual for scores and listings — nobody can tell 4,211 points from 4,236. Strong for “one vote per user per item”. Read-your-own-writes for your own posts, comments and votes.
- Spiky by nature. One thread can go from nothing to 200,000 concurrent readers in minutes (a cricket final, a product launch, breaking news), and that spike must not slow the rest of the site.
In the interview
Four questions shape this design. “Which sorts and time windows must we serve?” — each one is a structure to maintain. “How fresh must scores be?” — seconds, not milliseconds, unlocks batching. “How much traffic is logged out?” — on a site like this it is a large share, and the cheapest to serve. “Is the home feed a merge of joined communities or a personalised, ML-ranked feed?” — the first is a data-structure problem, the second a recommendation problem.
Out of scope
- Personalised, ML-ranked recommendations. We build the classic merged feed; see design a recommendation feed.
- Search. A separate index fed by the same events; see design post search.
- Chat, private messages and notifications. Separate products with their own real-time systems.
- Media processing. Resizing and transcoding are a pipeline of their own.
- Ads, awards and karma. Karma is a side effect of votes, nothing more here.
- Spam-model internals. We design where a classifier plugs in, not the classifier.
The set-up
Plan the approach
Walk the five requirements with the simplest thing that works: posts, votes and comments tables and ORDER BY. Then say why ORDER BY dies — 90k sorted reads a second while scores change thousands of times a second — and spend the deep dives on its replacements: a precomputable ranking formula, event-fed listings, batched vote counting, comment trees served in slices, a merged home feed, and caching for the one thread everybody is reading.
Core entities
- User: ID, name, created at, karma (derived from votes).
- Community (subreddit): ID, name, type (public, restricted, private), rules, moderators.
- Post: ID, community, author, kind (link, text or image), title, URL or body or media ID, created at, ups, downs, comment count, removed flag.
- Comment: ID, post, parent comment (none for top level), author, body, created at, ups, downs, removed or deleted flags.
- Vote: user, item (a post or a comment), direction (+1, −1 or 0), time.
- Subscription: user and community.
- Listing (derived): for one community and one sort, the top 1,000 post IDs with their sort keys.
- Mod action: who removed or approved what, when and why — an append-only log.
The tables that matter, sketched in SQL:
CREATE TABLE posts (
id BIGINT PRIMARY KEY, -- time-ordered ID
community_id BIGINT NOT NULL,
author_id BIGINT NOT NULL,
kind TEXT NOT NULL, -- 'link' | 'text' | 'image'
title TEXT NOT NULL,
url TEXT, body TEXT, media_id TEXT,
ups INT NOT NULL DEFAULT 1, -- the author's own upvote
downs INT NOT NULL DEFAULT 0,
num_comments INT NOT NULL DEFAULT 0,
removed BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMPTZ NOT NULL
);
CREATE TABLE comments (
post_id BIGINT NOT NULL, -- partition key: a thread lives together
id BIGINT NOT NULL,
parent_id BIGINT, -- NULL means top level
author_id BIGINT NOT NULL,
body TEXT NOT NULL,
ups INT NOT NULL DEFAULT 1, downs INT NOT NULL DEFAULT 0,
removed BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMPTZ NOT NULL,
PRIMARY KEY (post_id, id)
);
CREATE TABLE votes (
user_id BIGINT NOT NULL, -- partition key
thing_id BIGINT NOT NULL, -- a post or a comment
direction SMALLINT NOT NULL, -- +1, -1 or 0
voted_at TIMESTAMPTZ NOT NULL,
PRIMARY KEY (user_id, thing_id) -- one vote per user per item
);
API
Submit a post. Images are uploaded first, straight to object storage with a pre-signed URL; the post carries only the media ID. The idempotency key makes a double-tapped “Post” button create one post.
POST /v1/communities/cricket/posts
Authorization: Bearer <token>
Idempotency-Key: 7f1c2a90
{ "kind": "image", "title": "Final over, from the stands", "media_id": "m_4b9e" }
{ "id": "p_9x2k", "permalink": "/r/cricket/comments/p_9x2k/" }
Vote. A PUT of the final state, not a toggle: 1 is up, -1 is down, 0 clears. Sending the same vote twice leaves exactly one upvote, so a retry after a flaky mobile connection is harmless.
PUT /v1/things/p_9x2k/vote
{ "direction": 1 }
Comment and read a thread. The thread response is a budgeted slice of the tree; every place it was cut carries a stub listing the hidden child IDs, which the client passes back to load more.
POST /v1/posts/p_9x2k/comments
{ "parent_id": "c_12", "body": "That yorker was unplayable." }
GET /v1/posts/p_9x2k/comments?sort=best&limit=200&depth=8
{
"comments": [
{ "id": "c_12", "depth": 0, "score": 1840, "body": "…", "my_vote": 1,
"replies": [ { "id": "c_77", "depth": 1, "score": 612, "body": "…", "replies": [] } ] }
],
"more": [ { "parent_id": "c_77", "count": 41, "children": ["c_90", "c_91", "c_95"] } ]
}
GET /v1/posts/p_9x2k/comments/more?children=c_90,c_91,c_95&sort=best
Listings and the home feed. Cursor pagination: after is the last post ID the client saw.
GET /v1/communities/cricket/posts?sort=top&t=week&limit=25&after=p_81aa
GET /v1/feed/home?limit=25&after=p_77c1
{
"posts": [ { "id": "p_81ab", "title": "…", "score": 4211, "num_comments": 380, "my_vote": 0 } ],
"after": "p_70de"
}
Moderation.
POST /v1/mod/things/p_9x2k/remove
{ "reason": "Rule 3: no spoilers in titles" }
Back-of-envelope estimates
Show the arithmetic; these numbers decide what you precompute and where you cache. (For the method, see back-of-envelope estimation.)
Reads. 130M daily users × 30 page or API loads each = 3.9B reads a day. 3.9B ÷ 86,400 ≈ 45k reads/s on average. Reddit’s 2017 talk shows night traffic at about half of daytime; plan for 2× the average at peak: about 90k reads/s, almost all of them sorted lists.
Writes. For scale: in 2017 Reddit reported about 1M posts and 75M votes a day with 320M monthly users. Today, assume:
- Posts: 2M ÷ 86,400 ≈ 23/s, about 50/s at peak.
- Comments: 10M ÷ 86,400 ≈ 116/s, about 230/s at peak.
- Votes: 300M ÷ 86,400 ≈ 3,500/s, about 7,000/s at peak.
Votes are 300 ÷ 312 ≈ 96% of all writes, and reads outnumber writes about 12 to 1 (45k ÷ 3.6k).
Listing updates. Assume 30% of votes land on posts: 7,000 × 0.3 = 2,100 post votes/s at peak. Each can move its post in six score-based listings (hot, controversial, and top for day, week, month and all time): 2,100 × 6 ≈ 12,600 sorted-set updates/s before batching. Easy for a sharded Redis in aggregate; the danger is many writers aiming at the same few popular listings.
Storage.
- Votes: 300M × ~40 B (two 8-byte IDs, direction, timestamp, row overhead) = 12 GB/day ≈ 4.4 TB/year.
- Comments: 10M × 1 KB = 10 GB/day ≈ 3.7 TB/year.
- Posts: 2M × 2 KB = 4 GB/day ≈ 1.5 TB/year.
- Total: 26 GB/day ≈ 9.5 TB/year before replication, about 28.5 TB/year with three copies. Images sit in object storage, outside this sum.
Listings in memory. 100K active communities × 8 listings (the six above plus new and rising) × 1,000 entries × ~100 B per sorted-set entry (the ID, the score and Redis’s skiplist and hash overhead) = 80 GB at most — a handful of cache nodes, and most communities never fill 1,000 slots.
The megathread. A 50,000-comment thread: the tree structure alone (ID, parent and a sort key, about 24 B each) is 50,000 × 24 B ≈ 1.2 MB; with 1 KB bodies the comments themselves are 50 MB. You can afford to load the structure; you cannot afford to load or render every comment.
The match thread. 200,000 people with one thread open, each refreshing every 30 seconds: 200,000 ÷ 30 ≈ 6,700 requests/s on one URL.
Tip
Three numbers shape this design: 90k sorted reads a second, 7k votes a second concentrated on whatever is popular now, and 6.7k requests a second on a single thread. Storage is unremarkable.
High-level design
Build the design one requirement at a time. By the end of this section it works for one region at moderate traffic; the deep dives make it survive the real numbers.
Users should be able to create communities and submit posts
Communities are a small, slow-changing table — 100K active rows — that every server can cache whole. Posts are where the volume is.
- For an image post, the client uploads the bytes straight to object storage with a pre-signed URL and gets back a media ID.
- The gateway authenticates the post request and applies a posting rate limit, stricter for new accounts.
- The post service checks the community and any ban, assigns a time-ordered 64-bit ID (see design a unique ID generator) and writes the row to the posts store, sharded by ID — nothing on the read path queries posts by community, because listings will do that job.
- It publishes
post_created. Listings, the spam check, search and the author’s profile react to that event, so a slow consumer never slows a submission.
Users should be able to vote on posts and comments
A vote changes two things: the record of what this user thinks of this item, and the item’s totals. Only the first must happen before you reply.
The vote service reads the user’s previous direction for the item, writes the new one, and turns the difference into a delta:
| Previous | New | Change to ups | Change to downs |
|---|---|---|---|
| none (0) | +1 | +1 | 0 |
| +1 | −1 | −1 | +1 |
| −1 | 0 | 0 | −1 |
| +1 | +1 (a retry) | 0 | 0 — no event at all |
Non-zero deltas go onto a vote-events stream keyed by item ID; a score worker applies them to ups and downs, and on to the author’s karma. The voter sees their arrow at once because pages overlay my_vote from the votes table, not from totals. Reddit’s archived configuration names the trick: a one-hour vote_queue_grace_period during which a cache hides that a vote is still unprocessed.
Users should be able to comment and reply to any depth
A comment is a row with a parent_id. Storing it is easy; reading a thread of thousands of them in the right nested order is the hard part, so the design keeps a separate, derived tree index per post.
- The comment service checks the parent belongs to the same post, writes the row to a comments store partitioned by
post_id, so a thread’s comments sit together, and emitscomment_created. - A tree worker — one writer per post, because events are keyed by post ID — adds the new ID under its parent in the post’s tree index and bumps
num_comments. - To read a thread, the thread API loads the index, picks the comments that fit on the page, fetches just those bodies from one partition, and nests them.
The tree index is derived and can be rebuilt from the rows — the first of several denormalised indexes in this design (see data modelling and denormalisation).
Users should be able to browse sorted listings and a home feed
The obvious query — SELECT … WHERE community_id = ? ORDER BY hot DESC LIMIT 25 — cannot run 90k times a second while thousands of scores change underneath it. Instead, keep each listing as a stored, sorted structure: one Redis sorted set per community and sort, holding the top 1,000 post IDs with their sort keys, and update it from events.
- Write side. A listing worker consumes new posts, score changes and removals:
ZADDto insert or re-score,ZREMto remove, then trim the set back to 1,000 entries. - Read side.
GET /r/cricket?sort=hotbecomesZREVRANGE lst:{cricket}:hot 0 24— 25 IDs in about a millisecond — then one multi-get against the post cache, then the user’s votes on those 25 IDs. Deeper pages use the last ID seen as a cursor, not an offset, because the list shifts while you scroll. - Home feed. Read the user’s joined communities, take the top of each community’s hot listing, and merge them. Every community’s listing is shared by all its members, so this costs reads, not per-user writes — a deep dive below.
Moderators should be able to remove posts and comments
Moderation has an intake side (what needs a human to look at it) and an enforcement side (making a decision stick everywhere).
- Intake. Reports and automated flags land in a per-community mod queue, ordered by report count and age. Every new post and comment passes through automod — community-written rules such as “hold posts from accounts younger than 7 days” — and a spam model, both as event-stream consumers, so they add nothing to submission latency.
- Enforcement. The mod service sets
removed = trueon the item in the source of truth, appends to the mod log, and emitsthing_removed. Listing workersZREMit from every sort of its community (and from cross-cutting listings such as the author’s profile); the thread and post caches are invalidated, and the CDN is purged by a surrogate key per post. - Belt and braces. Hydration drops any item whose
removedflag is set, so even before the event is processed the post no longer renders — a page might briefly show 24 posts instead of 25, which is why the API over-fetches a few IDs. A removed comment keeps its slot in the tree as “[removed]”, so its replies stay attached.
You now have a working Reddit for moderate traffic: posts, votes, comments, sorted listings, a home feed and moderation. The deep dives are about what breaks at 90k reads and 7k votes a second.
Deep dives
How do we rank posts so listings can be precomputed?
Every listing is only as cheap as its sort key. The question to ask of any ranking formula is: when does an item’s score change?
Bad: a time-decaying score computed at read time. A common recipe divides votes by a power of age, such as points ÷ (hours + 2)^1.5. It ranks well, but every score changes every second, so no stored order stays correct and you sort on each of 90k requests a second.
Good: a periodic batch. Every minute, recompute every young post’s decayed score and rewrite the listings. Reads become cheap, but votes take up to a minute to show, and the work scales with all live posts, not with activity.
Great: a score that ignores the clock. Reddit’s hot formula, from its open-sourced code, uses the post’s submission time instead of its age:
from math import log10
REDDIT_EPOCH = 1134028003 # a fixed instant in December 2005
def hot(ups: int, downs: int, created_at: float) -> float:
s = ups - downs
order = log10(max(abs(s), 1)) # 10 votes = 1, 100 = 2, 1,000 = 3
sign = (s > 0) - (s < 0) # +1, 0 or -1
return round(sign * order + (created_at - REDDIT_EPOCH) / 45000, 7)
Two properties follow, and the second is the one that matters for the design.
- Every 45,000 seconds — 12.5 hours — of newness is worth a factor of 10 in net votes. The log also means the first 10 votes count as much as the next 90, so an early burst gets a post noticed, while a 50,000-vote post does not sit on top for days. A negative score flips the sign and sinks the post.
- Nothing in the formula is “now”. A post’s hot score changes only when its votes change. Two posts never swap places because time passed; newer posts arrive with larger time terms and push older ones down. A sorted list stays correct until the next vote or post, so you maintain it on events instead of re-sorting it on a clock.
Apply the same test to the other sorts. Top changes only on votes, but the day, week and month versions must also drop posts that age out — Reddit’s archived code computes those in a periodic batch job and maintains the all-time list incrementally. Controversial is (ups + downs) raised to the power of smaller side ÷ larger side: 500 up and 480 down scores 980^0.96 ≈ 744, while 1,000 up and 100 down scores 1,100^0.1 ≈ 2. Rising depends on traffic as well as votes — the archived code ranks recent posts by upvotes per view — so a periodic job recomputes it every few minutes.
| Sort | Sort key | Changes when | How it is maintained |
|---|---|---|---|
| Hot | log10 of net score + submission time ÷ 45,000 | A vote or a new post | Sorted set, updated per batch of votes |
| New | Creation time | A new post | Append, trim to 1,000 |
| Top, all time | Net score | A vote | Sorted set, updated per batch of votes |
| Top, day / week / month | Net score inside the window | A vote, or a post leaving the window | Batch job every few minutes |
| Controversial | (ups + downs) ^ (smaller ÷ larger) | A vote | Sorted set, or with the batch job |
| Rising | Upvotes per view, recent posts only | Views and votes, constantly | Periodic job every few minutes |
| Best (comments) | Wilson lower bound of the upvote share | A vote on that comment | Per-post score map |
In the interview
Say it in one sentence: “The hot score uses the submission time, not the current time, so a post’s rank changes only when it gets a vote. That’s what lets me store each listing pre-sorted and update it from the vote stream instead of sorting on every read.”
How do we keep sorted listings fresh while votes pour in?
At peak, 2,100 post votes a second can each move a post in six listings, mostly in a few hundred popular communities.
Bad: cache the query and invalidate on change. A large community receives votes many times a second, so its cached listing is almost always invalid and you are back to running the ORDER BY per vote.
Good: mutate the stored list in place. This is what Reddit did for years, as Neil Williams describes in his 2017 talk. Listings stored (ID, sort value) pairs, so a vote processor could fetch the list, move one post and write it back — no longer a cache, in his words, but a denormalised index. The archived code caps each at 1,000 items and inserts only posts that beat the last entry. The catch is the lock that read-modify-write needs. In mid-2012 the vote queues backed up at peak, and adding processors made it worse: they all waited on the same popular listings. Partitioning the queues by subreddit ID (mod 10) fixed it until domain listings, which cut across those partitions, pushed the p99 up again. The final fix split each vote into several messages, each processed on the partition that owns the listing it touches. Lock contention hides in averages and shows in p99s.
Great: let the store do the atomic work, and give each listing one writer.
- Sorted sets instead of blobs.
ZADD lst:{cricket}:hot 14648.51 p_9x2kre-scores one member atomically inside Redis; no read-modify-write happens in your code, so there is no lock to hold. Trim withZREMRANGEBYRANK key 0 -1001in the same pipeline or Lua script. - One writer per listing. Partition the event stream by the listing’s owner — the community ID for community listings — and emit separate messages for cross-cutting listings (domain, author profile), each keyed by its owner. One consumer per partition means no two workers ever write the same listing concurrently, and per-community order is preserved.
- Coalesce. The worker buffers score changes for one second, keeps only the latest totals per post, and writes each post once per listing per second. A post receiving 100 votes a second costs one
ZADDper listing per second, not 100. The worker computes the new key from the post’s current totals (emitted by the score aggregator), never by adding deltas inside Redis, so a replayed event cannot double-count. - Skip the irrelevant. If a post is not in the set and its new score is below the 1,000th entry, do nothing — most votes on week-old posts touch no listing at all.
What you pay: listings lag votes by a second or two plus queue delay, and a suddenly huge community — r/Cricket during a final — makes its partition hot, though coalescing caps each post at one write per listing per second. And because listings are derived, keep a rebuild path that reselects a community’s top 1,000 per sort from a replica, throttled, and replays events from the rebuild’s start. Reddit learned why in 2011, when a bug sent writes to a replica that was later rebuilt and listings ended up pointing at posts that no longer existed. If you denormalise, build the healer first.
How do we count votes without double-counting or melting a hot post?
Voting has three jobs: record each user’s vote exactly once, turn votes into scores, and resist manipulation.
Recording. The votes table’s primary key is (user_id, thing_id), partitioned by user_id. That choice serves the read that happens on every page for a logged-in user — “which of these 25 posts have I voted on?” — as a single-partition lookup. The write reads the old direction and stores the new one in one conditional step (a row lock, or a compare-and-set such as UPDATE … IF direction = <old>), retrying on conflict, so a frantic up-down-up from one user cannot interleave into two upvotes. Because the request carries the final state, a retry finds old equal to new and emits nothing: the operation is idempotent by construction. A second copy keyed by thing_id, written asynchronously, supports recounts and abuse investigations.
Counting.
- Bad:
UPDATE posts SET ups = ups + 1inside the vote request. A viral post at 100 votes a second serialises 100 transactions a second on one row, and your vote latency now depends on the most popular post on the site. - Good: per-vote increments from a queue. The request no longer waits, but the database still does one row update per vote, the hot row still contends, and at peak the queue backs up — the 2012 failure above.
- Great: aggregate per item per second. Key the vote stream by item ID. An aggregator — a small stream job (see Kafka and stream processing) — sums deltas per item over one-second windows and issues one
UPDATE … SET ups = ups + :du, downs = downs + :ddper item per window, then emitsscore_changed(item, ups, downs)for the listing and tree workers. At 7,000 votes a second spread over a few thousand items, that is a few thousand row updates a second instead of 7,000, and the viral post costs one update a second instead of 100. Commit the stream offset together with the update (or store the last applied offset per partition) so a restart does not apply a window twice — and run a periodic recount from the votes table anyway, because derived counters drift.
Resisting manipulation, quietly. Make cheating expensive and unobservable rather than blocking it with errors that teach the cheater. Reddit’s archived vote model records, for every vote, whether it affects the score and whether it affects karma, decided by a pluggable validator — so a suspicious vote can be stored, shown to its author as cast, and count for nothing. Signals include account age, voting speed, many accounts behind one network, and groups that always vote together. Reddit’s FAQ adds two display tricks: up and down totals are fuzzed while the net score stays correct, and scores can be hidden for a post’s first hours to dampen the bandwagon effect. Either way, a bot operator cannot easily tell whether their votes landed.
How do we sort comments so a good late comment can still win?
Bad: net score. In his 2009 post introducing the “best” sort, Randall Munroe counted the top ten comments of a big eight-hour-old thread: all ten had been posted in the first hour or two. Net score is a feedback loop — the top comment is read most, so it gets the most votes.
Bad: upvote share. It fails the other way: one upvote and no downvotes (100%) beats 100 up and 1 down.
Great: the Wilson lower bound. Popularised by Evan Miller’s essay “How Not To Sort By Average Rating”, it treats a comment’s votes as a sample of what everyone would think, and ranks by the lower bound of the Wilson score confidence interval on the true upvote share: “given these votes, the real share is at least this, with high confidence”. Few votes give a wide interval and a low bound; many votes narrow it.
from math import sqrt
Z = 1.281551565545 # the constant in Reddit's archived code, labelled "80% confidence"
def confidence(ups: int, downs: int) -> float:
n = ups + downs
if n == 0:
return 0.0
p = ups / n
return (p + Z*Z/(2*n) - Z*sqrt(p*(1-p)/n + Z*Z/(4*n*n))) / (1 + Z*Z/n)
| Comment | Up | Down | Net (top) | Upvote share | Wilson bound |
|---|---|---|---|---|---|
| A | 1 | 0 | 1 | 100% | 0.378 |
| B | 10 | 1 | 9 | 91% | 0.739 |
| C | 40 | 20 | 20 | 67% | 0.585 |
| D | 600 | 400 | 200 | 60% | 0.580 |
Three sorts, three orders. Net score says D, C, B, A; share says A, B, C, D; the Wilson bound says B, C, D, A — the 10-to-1 comment beats the 40-to-20 one, which is Munroe’s own example. One detail rewards reading the source: the blog post describes a 95% confidence bound, but the shipped code uses z ≈ 1.28, a looser bound that lets promising new comments climb a little faster, and precomputes the value for every comment with fewer than 400 upvotes and 100 downvotes — a 40,000-entry lookup table, since most comments only ever see a handful of votes.
Operationally, “best” is as friendly as “hot”: the bound depends only on the comment’s own votes, so it changes only when that comment gets one. The archived code stores, per post, a map from comment ID to its value for each sort; a score_changed event updates one entry, and rendering sorts each parent’s children with values already in memory. The bound also self-corrects: a promising new comment is shown high, collects votes quickly, and either confirms its place or sinks.
How do we store and serve a 50,000-comment thread?
Two separate problems hide here: storing a tree so you can fetch any part of it, and deciding which slice of a huge tree to show.
Storing the tree. An adjacency list (each comment stores parent_id) makes a reply one row, but reading a thread needs a recursive query, one level per iteration. A materialised path (each comment stores c12.c77.c90, as PostgreSQL’s ltree type does) turns a subtree into one prefix scan, but path order is creation order, so siblings still need sorting by score. A closure table stores every (ancestor, descendant) pair, so any subtree is one lookup — at the price of d + 1 rows per reply at depth d.
Reddit took a fourth route: comments are plain rows keyed by (post_id, id), plus one derived tree index per post — parent → children, depth, descendant counts and sort values. Neil Williams describes keeping the whole tree’s parent relationships in one place so the site can decide ahead of time which comments to show, and the archived code stores exactly that: one map from comment ID to child IDs per post, rewritten under a per-post lock. Its comments also record two abandoned attempts to spread the tree across Cassandra columns — one because counters proved unreliable, one because of garbage-collection pressure.
| Layout | Write a reply | Read a whole thread | Read one subtree | Watch out for |
|---|---|---|---|---|
| Adjacency list | 1 row | Recursive query, every row | Recursive query | Many round trips on deep threads |
| Materialised path | 1 row + parent’s path | Prefix scan, every row | One prefix scan | Long paths; siblings still need sorting |
| Closure table | d + 1 rows | Join on the root, every row | One indexed lookup | Rows grow with depth × comments |
| Per-post tree index | 1 row + 1 index update | Load a ~1 MB index, then only the chosen rows | Same index | One writer per post; ordering faults |
Almost every read is “the first page of this thread in this sort”, and the index answers that from memory: even the megathread’s is about 1.2 MB.
Choosing the slice. With the index in memory, the thread API does a budgeted walk:
- Go depth first from the top-level comments, in the requested sort, while the page budget lasts — say 200 comments (Reddit’s archived defaults were 100, up to 500) — and never deeper than 8 levels. Each parent’s share of the budget shrinks with depth, so one long argument cannot eat the page.
- Wherever the walk stops, emit a stub: the parent ID, how many comments are hidden, and their IDs from the index. “Load more” sends those IDs back; at the depth cap, the stub links to a page rooted at that comment.
- Fetch the 200 chosen bodies from the post’s partition in one read, and overlay the user’s own votes.
Writing to a megathread. A busy match thread can take 50 comments a second. The tree worker that owns the post applies a second’s worth in one batch — one index rewrite instead of 50, as Reddit’s comment-tree jobs do. Keying events by post ID keeps a reply behind its parent; if a child still arrives first, park it and retry rather than rebuilding the tree.
Isolate hot work, and cap it
In 2016 a breaking-news thread was slowing Reddit’s comment processing, so it was moved to a dedicated “fast lane” queue. Its earlier comments were still in the old queue, the tree looked inconsistent, and every page view enqueued a “recompute me” message. The fast lane filled the broker’s memory; the only way out was a restart that lost every queued message on the site. The fix was queue quotas. Give hot work its own capacity with a hard limit, and never let reads enqueue unbounded repair work.
How do we build the home feed from the communities a user joined?
Reddit’s feed is shaped differently from Twitter’s. On Twitter you follow people, and a celebrity’s post goes to millions of individual timelines. On Reddit you join communities, and every member of r/IndiaTech sees the same ranked list of r/IndiaTech posts. That shared list is the key.
Bad: query at read time. SELECT … WHERE community_id IN (…50 IDs…) ORDER BY hot LIMIT 25 scatters across shards and sorts thousands of rows, 90k times a second.
Also bad: fan-out on write. Copying each new post into every member’s feed, as follow-graph feeds often do (see design a news feed), turns one post to a 40-million-member community into 40 million writes. Worse, hot order changes with every vote, so every per-user feed would need re-ranking on every vote.
Good: merge shared listings at read time. The hot listing of every community already exists, sorted and shared. A user’s feed is a k-way merge of the tops of the listings they joined — fan-out on read (the trade-off is covered in push vs pull). Reddit’s archived front-page code does exactly this:
- it takes up to 50 joined communities (100 for paying members); if you joined more, a random sample is chosen and cached for a while so the page does not jump around, after dropping communities with no recent posts;
- it reads up to 150 posts from each community’s hot listing;
- it scales each list by its own top post’s score, so every community’s posts are placed by how far they sit below that community’s #1, rather than by raw vote counts where a 40-million-member community would bury a 4,000-member one;
- it heap-merges the lists and keeps the first 1,000 links.
Great: the same merge, with its inputs in memory and its output pinned. The merge itself takes microseconds; the cost is fetching 50 listings per request. But listings are shared and popularity is skewed, so keep the top 150 hot entries of the 10,000 most-joined communities in every feed server’s memory, refreshed every few seconds: 10,000 × 150 × 24 B = 36 MB per server. Most requests then touch Redis only for long-tail communities. Cache each user’s merged 1,000 IDs for about 10 minutes, so scrolling pages through one stable snapshot with no duplicates or gaps. What you pay: a feed a few seconds stale, and a snapshot to invalidate when the user joins or leaves a community.
Note
This classic merged feed is still the right first answer, and a good candidate generator later. A personalised feed adds a ranking model on top of candidates drawn from these same listings; the data structures do not change.
How do we survive one thread getting a stadium’s worth of readers?
It is the last over of an IPL final and the match thread in r/Cricket has 200,000 people on it: about 6,700 requests a second on one URL, 50 new comments a second, hundreds of votes a second. Everything about that thread is a hot key.
Bad: treat it like any other page. Every request rebuilds the thread, and the post’s cache entries live on one cache node, which saturates and slows every unrelated key on it.
Good: a CDN for logged-out readers. A logged-out page is identical for everyone (per sort), so serve it from the CDN with a short TTL and stale-while-revalidate. Origin then sees about one request per edge location per TTL, however many people are reading. Reddit used exactly this for r/place in 2017: the full board was cached at the CDN for one second with stale-while-revalidate, and with about 33 edge locations the application never saw more than 33 board requests a second. Assume 60% of the match-thread readers are logged out (4,000 req/s) and a CDN with about 100 locations: with a 5-second TTL, origin sees 100 ÷ 5 = 20 requests a second per sort. The cost: logged-out readers see the thread up to 5 seconds late, and a removal must purge the CDN (or wait out the TTL).
Great: also split logged-in pages into a shared body and a per-user overlay. The other 2,700 requests a second are logged in, and their personal bits — your votes, authors you blocked — are a small layer over a large shared part.
- Shared body: the chosen comment slice with bodies and scores, cached for 2 seconds per (post, sort), and held in each server’s memory rather than on one cache node, so the hot key is replicated across the fleet instead of hammering one shard.
- Single flight: when the body expires, one request per server rebuilds it while the others serve the stale copy — no stampede of identical rebuilds.
- Per-user overlay: one read of the user’s votes for the ~200 comment IDs on screen (a single partition of the votes table), plus their block list, merged in at the end.
- Isolation: route thread pages, listings and votes to separate server pools — Reddit’s 2017 load balancer already split request paths into pools so a slow comments page could not drag down the front page — and give hot threads their own tree-worker capacity with a quota, per the fast-lane lesson.
What you pay is freshness — up to 5 seconds logged out, 2 logged in — and pages assembled from two layers. If the product wants comments to stream in as they are posted, that is a push problem rather than a caching one: see design live comments.
The final design
Put the pieces together. Reads are served from precomputed, shared structures; writes are small rows plus events; everything sorted or counted is derived by workers off the request path.
- Logged-out read. The CDN serves listings and thread pages from a 5-second cache with stale-while-revalidate, so a viral thread costs origin about one request per edge location per 5 seconds.
- Logged-in read. A listing reads 25 IDs from a Redis sorted set; the home feed merges up to 50 joined communities’ hot listings, popular ones from memory; a thread page walks the post’s tree index within a 200-comment budget. All three hydrate from caches and overlay the user’s votes from one partition of the votes table.
- Post or comment. Write services validate, write a row (posts sharded by ID, comments partitioned by post) and publish an event. Image bytes went straight to the media store first.
- Vote. One conditional write replaces the user’s previous direction; the reply is immediate, and a non-zero delta is published.
- Derive. Workers, one writer per item, thread or listing, fold a second of vote deltas into one update per item, batch new comments into tree indexes, and run automod and the spam model.
- Re-rank and remove. Listing workers re-score posts at most once per second per listing, insert new posts, trim to 1,000 and
ZREMremoved posts from every sort, while caches and the CDN are purged.
Check it against the requirements. Reads never sort or count, so 90k pages a second cost a few key lookups each, well inside 100 ms at p50 and 300 ms at p99. Votes return after one conditional write; scores and listings follow within seconds. The source of truth is small replicated rows, so acknowledged writes survive, and every listing, score, tree and feed can be rebuilt from it. Consistency is strong where it matters — one vote per user per item, enforced by the primary key — and eventual elsewhere. And a match thread’s 6,700 requests a second land mostly on the CDN and on shared in-memory bodies, with separate pools and queue quotas protecting the rest of the site.
What interviewers look for by level
| Area | Mid-level | Senior | Staff |
|---|---|---|---|
| Requirements | Posts, votes, comments, a front page | Names every sort and window; sets a freshness budget in seconds | Separates shared from per-user data; plans for logged-out traffic and single-thread spikes from the start |
| Ranking | Sorts by votes or by date | Explains the hot formula’s log and time terms | Explains why a clock-free score makes precomputation possible; uses the Wilson bound for comments |
| Listings | ORDER BY with an index, maybe a cache |
Precomputed per community, updated on votes | One writer per partition, coalescing, a 1,000 cap, rebuild tooling; knows lock contention hides in p99s |
| Votes | Increments a counter per vote | Idempotent upsert keyed by user and item; async counting | Per-second aggregation, offset-safe updates, recounts, quiet anti-manipulation |
| Comments | parent_id and recursion |
Compares tree layouts; paginates with “load more” | Per-post tree index, budgeted walk with ID-carrying stubs, batched writes, ordering faults |
| Home feed | Queries all joined communities | Contrasts fan-out on write and on read; picks read | Normalised k-way merge, shared in-memory listings, pinned pagination snapshots |
| Spikes | “Add a cache” | CDN for logged-out pages | Shared body plus per-user overlay, single flight, isolated pools and queue quotas |
Follow-up questions
What if we want a personalised, ML-ranked home feed?
Keep everything above as the candidate generator. For each request, pull a few hundred candidates — the merged hot posts from joined communities plus posts from similar communities and trending ones — and send them to a ranking service that scores each with a model trained on clicks, dwell time, votes and hides. Cache the ranked result per user for a few minutes and paginate through it as with the snapshot above. The listings, votes and events are unchanged; what you add is a feature store, a model server with a latency budget of tens of milliseconds, and a fallback to the plain merge when the ranker is slow. The full design is in design a recommendation feed.
What if a group brigades votes onto one post?
Detection runs on the vote stream, not in the request: look for bursts of votes from accounts that arrived via the same external link, share networks, are newly created, or have a history of voting together. When the validator decides votes do not count, it marks them as not affecting score — they remain stored and visible to their authors — and the aggregator emits a negative correction so scores and listings re-rank on their own. Because the votes table keeps every vote and a by-item copy exists, you can re-evaluate an attack after the fact and recount the affected posts. Pair it with friction at the edges: rate limits on votes for new accounts, and hidden scores during a post’s first hours.
What if a user deletes a comment that has replies?
Do not remove the node from the tree, or every reply beneath it becomes an orphan. Blank the body and author, mark it deleted, and leave its slot in the tree index, so the thread renders “[deleted]” with its replies intact. A deleted comment with no replies can be dropped from the page entirely. The same rule covers moderator removals (“[removed]”). The comment row is purged later by a retention job, and the CDN and thread caches for that post are invalidated so the change shows within seconds.
What if we need r/all — hot across every community?
Two options. Merge at read time over the hottest communities, the same way as the home feed, using a precomputed list of the few thousand most active communities rather than a user’s subscriptions. Or keep one global hot listing: the listing workers additionally ZADD into it, but only when a post’s score passes a threshold (say, the current 1,000th entry), which filters out almost all writes. The global listing is a single very hot key, so replicate it read-only into every read server’s memory every few seconds instead of letting 90k requests a second hit one Redis shard. Filters such as hiding NSFW or user-muted communities are applied after the merge.
How to say it in the interview
“I’ll design Reddit’s core: communities, posts, votes, threaded comments, sorted listings and a home feed, at about 130 million daily users — roughly 90 thousand sorted reads a second and 7 thousand votes a second at peak.
The key decision is that no request ever sorts or counts. Posts, comments and votes are small rows in sharded stores, and every write also publishes an event. Workers turn those events into derived structures: each community’s top 1,000 posts per sort in Redis sorted sets, a per-post comment tree index, and vote totals. That’s possible because Reddit’s hot score is log of net votes plus submission time over 45,000 seconds — it uses the submission time, not now, so a post’s rank only changes when it gets a vote.
Votes are a PUT of the final direction, keyed by user and item, so retries are harmless and each user counts once. The vote service replies after one conditional write and emits the delta; an aggregator folds a second of deltas into one update per item, so a viral post costs one write a second, not a hundred. Listing workers each own a partition, so there are no application locks — the contention Reddit hit in 2012.
Comments are ranked by the Wilson lower bound of the upvote share, so a 10-to-1 comment beats an early 40-to-20 one. A thread page is a budgeted walk of the tree index — 200 comments, depth 8 — with stubs that carry the hidden IDs for ‘load more’.
The home feed is a k-way merge of the hot listings of up to 50 joined communities, each scaled by its top post; listings are shared, so there’s no fan-out on write. Finally, a match thread with 200 thousand readers is served from the CDN for logged-out users and from a shared two-second body plus a small per-user overlay for everyone else.”
Concepts used in this design
- Redis — sorted sets for listings, atomic
ZADD, and trimming to the top 1,000. - Kafka — the event stream, partitioned so every listing, thread and item has one writer.
- Stream processing with Flink — per-second vote aggregation with checkpointed offsets.
- Data modelling and denormalisation — listings, scores and tree indexes as derived, rebuildable data.
- Database sharding — posts by ID, comments by post, votes by user.
- Idempotency — votes as a
PUTof the final state, posts with an idempotency key. - Cursor pagination — paging through lists that change while you scroll.
- Push vs pull — why the home feed is merged on read instead of fanned out on write.
- Scaling reads — precomputation, caching and replicas for a 12-to-1 read-heavy site.
- Scaling writes — batching and coalescing hot counters.
- Content delivery network — short-TTL caching of logged-out pages.
- Hot keys, cache stampede and invalidation — single-flight rebuilds and replicated hot entries for a viral thread.
- Strong vs eventual consistency — strong for one vote per user, eventual for scores and listings.
References and further reading
- reddit-archive/reddit: _sorts.pyx (hot, controversy and confidence sorts)github.comThe exact hot formula (log10 of net score plus seconds since 1134028003 over 45,000), the controversy sort, and the Wilson confidence sort with z = 1.281551565545 and its 400 × 100 lookup table.
- reddit-archive/reddit: normalized_hot.py (front-page merge)github.comThe home page as a heap merge of per-subreddit hot listings: up to 150 links each, scaled by each list's top item, capped at 1,000 links.
- reddit-archive/reddit: comment_tree.py (comment tree storage)github.comOne stored parent-to-children map per post, written under a per-post lock, and two abandoned Cassandra column layouts (unreliable counters, GC pressure).
- reddit-archive/reddit (archived source of the r2 application)github.comListings capped at 1,000 precomputed items (queries.py), the 50-subreddit front-page limit (subreddit.py), comment page defaults of 100 and maximum 500, and the one-hour vote grace period (example.ini).
- Neil Williams, “Reddit's Architecture” (QCon San Francisco 2017, InfoQ transcript)infoq.com320M monthly users, 1M posts and 75M votes a day; listings as mutated-in-place denormalised indexes, vote-queue lock contention and partitioning, comment-tree jobs, the fast-lane incident and queue quotas.
- Randall Munroe, “reddit's new comment sorting system” (reddit blog, 2009, archived)web.archive.orgWhy sorting comments by net score favours early comments, and how the ‘best’ sort uses a confidence bound instead.
- Evan Miller, “How Not To Sort By Average Rating”evanmiller.orgWhy net score and average rating both mis-rank items, and the Wilson score lower bound that fixes it.
- Reddit Inc., “How we built r/place”redditinc.comA shared board cached at the CDN with a 1 s expiry and stale-while-revalidate, holding origin traffic to about one request per POP per second.
- Reddit Inc. home page (company statistics)redditinc.comPublished figures as of October 2026: 130M+ daily active uniques, 514M+ weekly, 100K+ active communities, 26B+ posts and comments.
- Reddit FAQ (2016 snapshot)web.archive.orgDisplayed up and down totals are fuzzed to frustrate spam bots while the net score stays correct; scores can be hidden for a post's first hours.