SYSTEM DESIGN · DESIGN PROBLEMS · FEEDS AND SOCIAL · MEDIUM · 14 OF 41

Design Reddit: Communities, Voting, Hot Rankings and Comment Trees

A step-by-step design for a community discussion site like Reddit: requirements, scale math that shows it is a read and ranking problem rather than a storage one, the API and data model, precomputed hot and top listings per community, a pull-and-merge home feed, voting at high write rates without double counts, nested comment trees, edge caching for logged-out readers, and the trade-offs.

  1. 1
  2. 2
  3. 3
  4. 4
  5. 5
  6. 6
  7. 7
  8. 8
  9. 9
  10. 10
  11. 11
  12. 12
  13. 13
  14. 14
  15. 15
  16. 16
  17. 17
  18. 18
  19. 19
  20. 20
  21. 21
  22. 22
  23. 23
  24. 24
  25. 25
  26. 26
  27. 27
  28. 28
  29. 29
  30. 30
  31. 31
  32. 32
  33. 33
  34. 34
  35. 35
  36. 36
  37. 37
  38. 38
  39. 39
  40. 40
  41. 41
1 / 18

Press play to hear the narration, or step through at your own pace.

Notes

Listen

Requirements. Users join communities and post links, text or images. Anyone can vote a post or comment up or down, but only once, and can change their vote. Each community has listings: hot, new and top. The home page merges your subscribed communities. Comments nest into threads. Chat, awards and the moderator tools are out of scope, though we'll keep removal of content in mind.

Qualities. Pages load in under three hundred milliseconds. Your own vote must appear instantly, but the score everyone else sees can lag a few seconds. Votes must never be double counted. A large share of readers are logged out and see identical pages, which is a gift: those can be cached at the edge. And a single breaking-news thread can take a sudden flood of comments and votes.

Functional requirements
  • in scope:
  • • communities (subreddits)
  • • post link, text or image
  • • up and down vote posts and
  • comments, one vote per user
  • • listings: hot, new, top
  • • home feed from subscriptions
  • • nested comments
  • out: chat, awards, moderation UI
Non-functional requirements
  • page load p99 under 300 ms
  • votes feel instant to voter
  • scores may lag a few seconds
  • for everyone else
  • no double counting of votes
  • most traffic is logged-out
  • readers: cache aggressively
  • spiky: one thread can get
  • a huge surge

Ask scale

Assume seventy-five million daily users with twenty page views each: one and a half billion views, about fifteen thousand a second, triple at peak. Votes are the busiest write: five per user is about four thousand a second, with spikes far higher on a breaking thread. Content creation is modest: about a million posts and ten million comments a day.

Storage is small. Posts, comments and vote records add up to roughly twenty gigabytes a day, about eight terabytes a year. A precomputed hot list of a thousand post IDs for each of a hundred thousand active communities is under two gigabytes, which fits in one cache cluster. Saying this out loud is valuable: it tells the interviewer you know where the real difficulty is, ranking and hot spots, not raw size.

Traffic
DAU                75M
page views/user     20
  1.5B / 100,000 s = 15K rps
  peak x3         = 45K rps

votes/user/day      5
  375M / 100,000  = ~4K vps
  hot thread peak = 20K vps

posts/day           1M
comments/day        10M
Storage
posts    1M x 2 KB   = 2 GB/day
comments 10M x 1 KB  = 10 GB/day
votes    375M x 24 B = 9 GB/day
                     ~ 21 GB/day
  x 365              = ~8 TB/yr

hot listing per sub: top 1,000
  ids x 16 B = 16 KB
  x 100K active subs = 1.6 GB

-> a ranking problem, not size

Define API

The API. Listings take a sort and an after cursor. The home feed is the same shape. Creating a post takes a title and a link or a body. Voting is a PUT that sets your vote to up, none or down, which makes it naturally idempotent: sending 'up' twice still means one up vote. Comments are fetched as a tree with a depth and size limit, and new comments name their parent.

Core API
GET  /v1/r/{sub}?sort=hot&after=..
GET  /v1/home?sort=hot&after=..
POST /v1/r/{sub}/posts
  { title, url | body }
PUT  /v1/vote
  { thingId, dir: 1 | 0 | -1 }
GET  /v1/posts/{id}/comments
     ?sort=best&depth=6&limit=200
POST /v1/comments
  { postId, parentId?, body }

Data model

Data model. Content is relational and modest in size, so PostgreSQL sharded by community or post ID works well. Posts carry denormalised score, up and down counts. Comments keep a parent ID plus a materialised path like one, four, nine for cheap subtree reads. Votes are one row per user and item, with that pair as the primary key, so a user physically cannot have two votes on the same thing. Subscriptions map users to communities.

Records
subreddit (subId, name, rules)
post   (postId, subId, authorId,
        title, url, ts, score,
        ups, downs, removed)
comment(commentId, postId,
        parentId, path, body, score)
vote   PK (userId, thingId)
        dir  -- one row per pair
subscription (userId, subId)

High-level design

The read path. Logged-out readers of a popular page are served from the edge cache with a short lifetime, maybe thirty seconds, so the origin never sees most of that traffic. Logged-in requests go to the listing service, which reads the top IDs from a sorted set per community and sort, hydrates them from a post cache, and overlays the viewer's own votes. Everything is a cache read.

The write path. New posts and comments go through the post service into the database and comment store. Votes take a separate path: the vote service records the user's vote, then emits a vote event. An aggregator sums events per item over a few seconds and applies one update to the score. Rank workers then recompute hot scores and update the sorted sets. A thousand votes become one database write.

Deep dive: votes

Deep dive one: voting. Upsert the user's vote row, reading the old direction and writing the new one. The change to the score is new minus old, so switching from down to up is plus two, and repeating the same vote is zero. That's what makes retries safe. Emit only that delta, keyed by item. The aggregator sums deltas for a couple of seconds and issues one update per item, so even a thread with twenty thousand votes a second costs a handful of row updates.

How does your own vote feel instant if scores update in batches? The client updates the arrow and score optimistically, and when the page reloads, the listing service overlays your votes from one batched lookup of the twenty-five items on the page. Abuse matters too: rate limit votes per account, detect vote rings offline and discard their votes, which is easy because every vote is a stored row.

Counting votes safely
  • 1 upsert vote(user, thing)
  • read old dir, write new
  • 2 delta = new - old
  • (down to up = +2)
  • 3 emit delta, keyed by thing
  • 4 aggregator sums deltas per
  • thing every ~2 s, 1 write
  • retry same vote: delta 0
  • hot item: one partition, still
  • just one row update per batch
Instant for the voter
  • client shows the new arrow
  • and score immediately
  • (optimistic UI)
  • viewer overlay: read my
  • votes for the 25 IDs on the
  • page, one batched lookup
  • vote rings, bots: rate limit
  • per account, fuzz displayed
  • counts, discard bad votes

Deep dive: ranking

Deep dive two: ranking. The hot formula Reddit open-sourced years ago takes the log of the net score and adds a term that grows with the post's creation time. So ten times more votes is worth one point, and being about twelve and a half hours newer is also worth one point. The neat property: the score depends on when the post was made, not on the current time, so you only recompute a post's hot score when its votes change, never on a timer.

Each community keeps sorted sets for hot, new and top by day, week and all time, trimmed to the top thousand. The home feed is pull, not push. You subscribe to maybe a hundred communities, each with a ready hot list in cache, so the listing service does a k-way merge of those lists by hot score and caches the result for a minute. No fan-out is needed, because the community lists are already shared by everyone.

Hot score
s     = ups - downs
order = log10(max(|s|, 1))
sign  = 1, 0 or -1 from s
age   = postTime - epoch, secs
hot   = sign * order + age/45000

10x the votes = +1 point
12.5 hours newer = +1 point

depends on post time, not now:
 only recompute when votes land
Listings and the home feed
  • per sub: sorted sets hot, new,
  • top:day, top:week, top:all
  • keep top 1,000, trim rest
  • home = pull + merge:
  • take hot lists of my subs
  • (cap ~100 subs sampled)
  • k-way merge by hot score
  • cache result ~1 min per user
  • no fan-out: subs are hot lists

Deep dive: comments

Deep dive three: comment trees. Store comments flat, each with its parent ID and a path. Loading a post's comments is one query by post ID; build the tree in memory and sort each set of siblings by score. Big threads have tens of thousands of comments, so cap the response at a couple of hundred and a depth of six, leaving 'load more' stubs that fetch a subtree by path. A deleted comment keeps its node with a blank body, so its replies still have a parent.

Comment trees
  • row: commentId, parentId,
  • path "1.4.9", depth, score
  • read all of a post: one query
  • by postId, build the tree in
  • memory, sort siblings
  • limit: 200 comments, depth 6
  • -> "load more" stubs
  • cache the rendered tree per
  • post + sort for ~30 s
  • delete: keep node, blank body

Trade-offs

Failures. A breaking thread can cause a cache stampede, thousands of requests rebuilding the same comment tree at once, so coalesce them: one rebuild, everyone else waits for it. If the aggregator is down, votes pile up safely in the stream and scores just lag. A lost listing cache is rebuilt from scores in the database. When moderators remove a post, purge it from the edge cache. Monitor score lag, cache hit ratio and p ninety-nine page time.

The trade-offs. Batched score updates give up a few seconds of freshness to cut database writes by orders of magnitude. Precomputed listings spend memory to make every listing a cache read. A pull-based home feed pays a small merge cost instead of a large fan-out. At ten times the scale, shard the database by community, cache more at the edge, and widen the vote stream's partitions.

Failures and monitoring
  • hot thread: cache its comment
  • tree, coalesce rebuilds
  • (one rebuild, many waiters)
  • aggregator down: votes queue
  • in the stream, scores lag
  • listing cache lost: rebuild
  • from DB scores, per sub
  • edge cache: short TTL, purge
  • on removal by moderators
  • watch: score lag, hit ratio
Trade-offs to name
  • batched scores: seconds of lag
  • for far fewer writes
  • precomputed lists: memory for
  • instant listings
  • pull home feed: merge cost
  • for zero fan-out
  • 10x: shard DB by sub, more
  • edge caching, partition vote
  • stream wider

Say it like this

The summary. Content is relational and small. Votes are stored one row per user and item, and only the delta goes to a stream, where an aggregator batches score updates. Hot scores combine the log of the score with a time term, recomputed only when votes arrive, and live in sorted sets per community. The home feed merges those lists. Comments are flat rows with paths, built into trees with limits. Logged-out pages come from the edge.

The 60-second answer
  • relational content, ~8 TB/yr
  • vote row per (user, thing);
  • emit delta, batch counts
  • hot = log score + time term,
  • recompute only on votes
  • sorted sets per sub and sort
  • home: k-way merge, no fan-out
  • comments: flat, path, sort
  • siblings, load more
  • edge cache for logged-out

Interview questions

How do you stop a user from voting twice?

Store votes in a table whose primary key is the user and item pair, so a second vote is an update, not a new row. The score change is the new direction minus the old one, so repeating the same vote changes nothing.

How do you handle thousands of votes per second on one post?

Send vote deltas to a stream partitioned by item and let an aggregator sum them for a second or two before writing one score update. The hot row is then updated a few times a second instead of thousands.

How does the hot ranking work, and when do you recompute it?

Hot combines the logarithm of the net score with a term proportional to the post’s creation time, so newer posts need fewer votes to rank. Because it does not depend on the current time, you recompute a post’s score only when its votes change.

Would you use push or pull for the home feed?

Pull. Each community already has a precomputed hot list in cache, so the home feed is a k-way merge of the user’s subscribed lists, cached briefly per user. Push would duplicate the same community posts into millions of inboxes for no benefit.

How do you store and load nested comments?

Store each comment as a row with post ID, parent ID and a materialised path, fetch a post’s comments with one query, and build and sort the tree in memory. Limit depth and count, and return load-more stubs that fetch a subtree by path.

Why is an edge cache so effective here?

Many readers are logged out and see exactly the same page, so a short-lived edge cache serves them without touching the origin. Logged-in pages differ only in the viewer’s votes, which can be overlaid on cached listings.

SQL or NoSQL for this design?

SQL works well because the data is relational and only a few terabytes a year; shard by community or post ID as it grows. The hot paths, listings and counters, live in a cache such as Redis sorted sets anyway.

What happens to replies when a comment is deleted?

Keep the comment row with its body blanked and a deleted flag, so the tree stays intact and replies still attach to their parent. Fully empty deleted branches can be pruned when rendering.