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.
- 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
- 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.
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
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.
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.
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.
- 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
- 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.
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
- 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.
- 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.
- 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
- 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.
- 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