Reddit reads like three easy problems stapled together. Posts in communities: a table. Voting: a counter. Comments: a tree. Say it out loud and the interviewer starts pulling threads. “A vote must feel instant but the displayed score must not be a live sum of a billion rows.” “The front page is sorted by hot, which is a function of both score and age, so the sort order of every post changes every second even when nobody votes.” “One popular post has 40,000 comments nested twelve levels deep and must paginate.” “A brigade of 5,000 bot accounts can drive a post to the top of a subreddit in ninety seconds, and you have to notice before it happens.”

The whole design is dominated by one asymmetry: voting is a firehose of tiny writes whose aggregate (the score) is read constantly and must be ranked in real time, while being defended against coordinated abuse. Every hard part - the score you cannot recompute per read, the hot sort that decays with time, the comment tree that will not fit in one query, the brigade you must catch - falls out of that. Let me do it properly.

Functional Requirements (FR)

In scope:

  • Communities (subreddits). Users create and join subreddits. Each subreddit is a namespace for posts with its own moderators and rules.
  • Posts. A user submits a post (title plus a text body or a link/media reference) to a subreddit. Posts are listable per-subreddit and on an aggregated front page.
  • Voting. Any user casts one upvote or one downvote per post and per comment, can change it, and can remove it. The net score drives ranking. One user, one vote per item - this is a core correctness constraint, not a nice-to-have.
  • Sorting. List posts by hot (score blended with recency, the default), new (pure recency), and top (highest score within a time window: day/week/month/all). Comments sort by best, top, new, controversial.
  • Comments. Threaded, arbitrarily nested comment trees under a post. Comments are themselves votable and sortable.
  • Abuse resistance. Voting must resist brigading and bot rings - coordinated bursts of votes designed to manufacture rank.

Explicitly out of scope (say this so you control the scope):

  • Full-text search over posts and comments. Its own system (an inverted index in Elasticsearch); I will mention where it plugs in.
  • Media storage and transcoding. Posts carry a URL to a separate blob/CDN system; I am not designing that.
  • Direct messages, chat, notifications, awards, the karma economy beyond a simple counter, and the recommendation/personalization layer.
  • Moderation tooling and content policy pipelines. They exist and consume the same event stream; I am not building them here.

The one decision that drives everything: this is a read-heavy ranking system sitting on top of a write-heavy vote stream, where the ranking key (hot) is time-dependent and the write stream is actively adversarial. That combination is the whole problem.

Non-Functional Requirements (NFR)

  • Scale: ~50M DAU. Heavily read-skewed - the vast majority of users lurk and vote, few post. Assume tens of millions of votes/day and millions of posts and comments/day.
  • Latency: listing a subreddit or the front page p99 under 200ms - this is what users feel on every pull-to-refresh. Casting a vote should feel instant (optimistic UI, p99 acknowledge under 150ms); the authoritative score can settle a few seconds later.
  • Availability: 99.99% on the read path (feeds and comment loads). A blank front page is the most visible failure. Voting can tolerate marginally lower availability because it is buffered and async behind the optimistic UI.
  • Consistency: eventual for scores and rank. It is completely fine if a post’s displayed score lags the true count by a couple of seconds, or if your vote takes a moment to reflect for other users. The one exception is one-user-one-vote: a single user must never be double-counted, even if they tap twice or the request retries. That is an idempotency requirement, enforced per (user, item).
  • Durability: a submitted post or comment must never be lost. Raw votes must be durable too - they are the audit trail we replay for abuse detection and recount. A cached score can be rebuilt from the vote log; the vote log itself cannot be lost.
  • Freshness: a vote should move the visible score and re-rank the post within a few seconds under normal load.

Back-of-the-Envelope Estimation (BoE)

Real numbers with the arithmetic, because the architecture is dictated by them.

Reads (feed and comment loads):

50M DAU, each opening the app and refreshing many times.
Assume ~30 feed/comment fetches per user per day (front page, subreddit
lists, opening posts, pull-to-refresh, scroll pages).
50M * 30 = 1.5B reads/day
= 1.5B / 86,400 s ≈ 17,000 reads/sec average
Peak (3x average) ≈ 52,000 reads/sec

Votes (the write firehose):

Voting is the dominant write. Assume each DAU casts ~40 votes/day
(cheap, one-tap, lurkers vote a lot).
50M * 40 = 2B votes/day
= 2B / 86,400 ≈ 23,000 votes/sec average
Peak (3x) ≈ 70,000 votes/sec

So peak is roughly 52K feed reads/sec and 70K votes/sec. Note that votes actually out-number feed reads in raw write volume - that is unusual and it is why the vote path gets its own dedicated ingestion pipeline rather than being a plain DB update.

Posts and comments (the durable content writes):

Posting is rare relative to voting. Assume ~2M posts/day and ~20M comments/day.
Posts:    2M / 86,400   ≈ 23 posts/sec  (peak ~70/sec)
Comments: 20M / 86,400  ≈ 230 comments/sec (peak ~700/sec)

Tiny compared to votes. Content creation is not the scaling problem; vote ingestion and ranking are.

Storage:

Per post row:

post_id        8 bytes  (Snowflake-style 64-bit, time-sortable)
subreddit_id   8 bytes
author_id      8 bytes
title          ~300 bytes
body/url       ~1 KB (text body budget; link posts far smaller)
created_at     8 bytes (also encoded in post_id)
metadata       ~200 bytes
------------------------------------------------
≈ 1.5 KB/post
2M posts/day * 1.5 KB      ≈ 3 GB/day  → ~1.1 TB/year of posts
20M comments/day * 400 B   ≈ 8 GB/day  → ~2.9 TB/year of comments

Content is only a few TB/year - trivial for a sharded cluster. The interesting store is votes:

Raw vote record: (user_id 8, item_id 8, value 1, ts 8, ~small metadata)
≈ 40 bytes durable per vote.
2B votes/day * 40 B ≈ 80 GB/day → ~29 TB/year of raw votes.

29 TB/year of raw votes is real but tractable, and most of it is cold audit data that ages into cheaper storage after the abuse-detection window closes.

Score cache size:

The hot working set is the score and rank metadata for active posts, not all posts ever. Only recent posts get ranked into feeds.

Assume ~50M "active" posts (roughly the last ~30 days that can appear in feeds).
Per post in cache: post_id + score + up + down + created_at + hot_score
≈ 64 bytes.
50M * 64 B ≈ 3.2 GB for the ranking metadata.

Tiny. The ranked feed lists themselves (per-subreddit sorted ID lists) are the bigger cache item, but still on the order of tens of GB, comfortably in a Redis cluster.

Bandwidth:

A feed page = ~25 posts * ~1.5 KB hydrated ≈ 40 KB.
Read bandwidth at peak: 52K reads/sec * 40 KB ≈ 2 GB/sec served.

Handled by CDN for media and horizontal read scaling for post/comment bodies. The hard constraint is vote write volume and keeping hot-ranked feeds fresh, not raw bandwidth.

High-Level Design (HLD)

Three paths with very different profiles: the content write path (rare, durable, simple), the vote path (the write firehose, buffered and aggregated), and the read/feed path (high-QPS, latency-critical, served from precomputed ranked lists). Votes are deliberately decoupled from the score update by a queue and an aggregator so that 70K votes/sec never turn into 70K contended row updates.

                          ┌──────────────────────────┐
                          │         Clients          │
                          │  (mobile, web, API)      │
                          └────────────┬─────────────┘
                                       │
                          ┌────────────▼─────────────┐
                          │       Load Balancer       │
                          └───┬───────────┬───────┬───┘
             CONTENT WRITE    │    VOTE    │  READ │  FEED
            ┌─────────────────▼─┐   ┌──────▼────┐  └──▼───────────────┐
            │   Post / Comment   │   │   Vote    │   │  Feed Service    │
            │   Service          │   │  Service  │   │  (stateless)     │
            └───┬────────────────┘   └────┬──────┘   └───┬──────────────┘
                │                         │              │ read ranked list
     write post │           enqueue vote  │              │ (post_ids) + scores
     (durable)  │           (dedup'd)     │          ┌───▼────────────┐
        ┌───────▼──────┐          ┌───────▼──────┐   │  Feed Cache     │
        │ Content Store │          │  Vote Queue  │   │ (Redis: subr →  │
        │ (posts,       │          │   (Kafka,    │   │  sorted ZSET of │
        │  comments;    │          │  partitioned │   │  post_ids)      │
        │  sharded)     │          │  by item_id) │   └───┬────────────┘
        └───────────────┘          └───────┬──────┘       │ hydrate ids
                │                          │          ┌───▼────────────┐
                │                 ┌────────▼───────┐  │  Content Cache  │
                │                 │ Vote Aggregator │  │ (Redis: id →    │
                │                 │  (dedup, count, │  │  post/comment   │
                │                 │  fraud signals) │  │  body)          │
                │                 └───┬─────────┬───┘  └────────────────┘
                │      write raw vote │         │ push score delta
                │        (audit log)  │         │ + recompute hot
                │            ┌────────▼───┐  ┌───▼────────────┐
                │            │  Vote Store │  │ Ranking /      │
                └───────────▶│ (append log,│  │ Score Store    │
                             │ sharded by  │  │ (item → score, │
                             │  item_id)   │  │  up/down, hot) │
                             └─────┬───────┘  └───┬────────────┘
                                   │              │ feeds scores
                             ┌─────▼──────────────▼───────┐
                             │  Abuse / Anti-Brigade       │
                             │  Service (stream analytics  │
                             │  over the raw vote log)     │
                             └─────────────────────────────┘

Content write flow (submit a post or comment):

  1. Client POST /posts (or /comments). Post/Comment Service validates, assigns a time-sortable Snowflake id, and writes the row to the durable Content Store. Comments also carry post_id and parent_id.
  2. Write into the Content Cache and seed a Score Store entry (score=0, up=0, down=0).
  3. Emit a content_created event so the item can be inserted into the relevant ranked feed lists (new immediately; hot/top once it has votes).

Vote flow (the firehose):

  1. Client POST /vote {item_id, value}. Vote Service authenticates and immediately writes an idempotent “current vote” record keyed by (user_id, item_id) - this is what enforces one-user-one-vote and lets the client render optimistically.
  2. Vote Service enqueues the vote onto Kafka, partitioned by item_id, and acknowledges. The user’s tap is done in well under 150ms; the heavy work is downstream.
  3. The Vote Aggregator consumes the partition, deduplicates against the previous vote for that (user, item), updates in-memory counters, and periodically flushes score deltas to the Score Store. It also appends the raw vote to the durable Vote Store (the audit log) and emits signals to the Abuse Service.
  4. When a score delta lands, the Ranking layer recomputes that item’s hot/top scores and updates the sorted feed lists (Redis ZSETs).

Read/feed flow - the hot path:

  1. Client GET /r/{subreddit}?sort=hot. Feed Service reads the precomputed sorted list of post_ids for that subreddit and sort from the Feed Cache (a Redis ZSET range read). Fast.
  2. Hydrate the top N IDs into full post objects from the Content Cache with a batched MGET, attaching the current score from the Score Store.
  3. Overlay the requesting user’s own vote state (did I upvote this?) from their vote records so the arrows render correctly.
  4. Return the page. Cache misses fall through to the Content Store.

The key insight is in the split: the vote firehose is absorbed by a queue and an aggregator so scores update in batches, and feeds are served from precomputed sorted lists so a read is a range scan, not a re-sort. That is what buys sub-200ms feeds while ingesting 70K votes/sec.

Component Deep Dive

The hard parts, naive-first then evolved: (1) vote counting at scale with one-user-one-vote, (2) the hot sort and why it re-ranks with time, (3) the comment tree, (4) anti-brigading.

1. Vote counting: the write firehose and one-user-one-vote

This is the heart of the system. A vote is conceptually UPDATE posts SET score = score + 1 WHERE id = ?. Do that literally and you die three different ways.

Approach A: synchronous counter update (the naive query)

Each vote does, in the request path:

BEGIN;
  INSERT INTO votes (user_id, item_id, value) VALUES (?, ?, ?)
    ON CONFLICT (user_id, item_id) DO UPDATE SET value = excluded.value;
  UPDATE items SET score = score + :delta WHERE id = :item_id;
COMMIT;

Where it breaks:

  • Row contention on hot posts. A front-page post takes tens of thousands of votes per minute. Every one of those tries to UPDATE the same row, so they serialize on a single row lock. Throughput on that one post collapses to the rate at which one row can be locked, updated, and committed - a few thousand/sec at best, far below the burst. The hottest, most important posts are exactly the ones that jam.
  • 70K writes/sec of transactions across the cluster is a lot of synchronous, durable, contended work for something that does not need to be authoritative in real time.
  • The score UPDATE and the INSERT are coupled in one transaction, so the slow, contended part (the counter) gates the fast part (recording the user’s vote).

Approach B: decouple the vote record from the count, aggregate asynchronously (the answer)

Split the vote into two independent facts:

  1. The user’s current vote - an idempotent record keyed by (user_id, item_id) with value in {+1, 0, -1}. This is written synchronously and is the source of truth for one-user-one-vote and for rendering the user’s own arrows. It is a single-key upsert, no contention across users because each user touches only their own key.
  2. The aggregate score - updated asynchronously, in batches, by an aggregator that consumes the vote stream.
on vote(user, item, value):
    old = votes.get(user, item)              # +1 / 0 / -1, default 0
    if old == value: return                  # idempotent: no-op, dedup double taps
    votes.put(user, item, value)             # synchronous, per-user key, no contention
    delta = value - old                      # e.g. up→down is -2
    kafka.produce(partition=item, {item, delta, user, ts})
    ack()                                     # done in <150ms

The aggregator, per Kafka partition (partitioned by item_id, so all votes for one item are ordered on one partition and processed by one consumer):

on batch(votes for items):
    for item, votes in group_by_item(batch):
        net = sum(v.delta for v in votes)     # combine thousands of votes
        up  = count(v for v in votes if v.value==+1)
        down= count(v for v in votes if v.value==-1)
        score_store.increment(item, net, up, down)   # ONE write per item per flush
        recompute_hot(item)                          # update ranked lists
        vote_store.append(votes)                      # durable audit log
        abuse.emit(votes)                             # fraud signals

Why this fixes all three failures:

  • Contention is gone. Thousands of votes for one hot post arrive on one Kafka partition, get combined by one aggregator into a single +4,213 delta, and hit the Score Store as one write per flush interval (say every 1-2 seconds). The hottest post now generates roughly one score write per second instead of thousands of contended row locks. Hot posts are the ones that benefit most.
  • The write path is cheap and never blocks. The user’s tap only does a per-user key upsert plus a Kafka produce. No hot-row lock is ever taken in the request path.
  • One-user-one-vote is exact because it is enforced at the (user_id, item_id) key, which is idempotent by construction: re-submitting the same value is a no-op, and a change computes a delta relative to the stored value. A double-tap or a client retry cannot double-count.

The trade-off is that the displayed score is eventually consistent - it lags the true count by the flush interval. That is explicitly acceptable per the NFRs; nobody needs the score to be a live sum. Reddit itself has always shown “fuzzed” and slightly-delayed vote counts.

ApproachVote write costHot-post behaviorConsistencyVerdict
Synchronous counterContended transaction per voteSerializes on one row, collapsesStrongDies on hot posts
Idempotent record + async aggregatePer-user upsert + enqueueCombined into one write/flushEventual (seconds)The answer

Sharding the counters: the Score Store is sharded by item_id, so different posts live on different shards and there is no global counter. Within the aggregator, partitioning the Kafka topic by item_id guarantees per-item vote ordering (so an up-then-down for the same user resolves correctly) while spreading load across many partitions and consumers.

2. The hot sort and why the feed re-ranks with time

The naive sort: order posts by score DESC. Simple, and it is exactly top-of-all-time.

Where it falls short (it works, it is just wrong for a front page): a two-year-old post with 90,000 upvotes would sit permanently above a one-hour-old post with 900, so nothing new ever surfaces and the front page is frozen. The whole point of hot is that a post’s rank must decay as it ages, so fresh content rises and stale content sinks even with no new votes.

Evolved design: hot as a score that blends votes with age. The canonical Reddit hot formula:

hot(ups, downs, created_at):
    s = ups - downs
    order = log10(max(|s|, 1))                 # diminishing returns on votes
    sign  = +1 if s > 0 else (-1 if s < 0 else 0)
    seconds = created_at - EPOCH               # age relative to a fixed epoch
    hot = round(sign * order + seconds / 45000, 7)

Two properties matter. First, log10 means the 10th upvote matters as much as the next 90: early votes move a post a lot, later votes barely. Second, and this is the clever part, the recency term (seconds / 45000) is a function of the post’s creation time, which is fixed, not of “now”. The constant 45000 means every ~12.5 hours a post needs 10x more net votes to hold the same hot score as a fresh post.

Because hot uses the post’s fixed created_at and not the current time, you do not have to recompute every post’s hot on every request. A post’s hot score only changes when its vote count changes. So:

  • When the aggregator updates a post’s score, it recomputes that one post’s hot and updates its position in the per-subreddit sorted list (a Redis ZSET: ZADD subreddit:hot <hot_score> <post_id>).
  • A feed read is then ZREVRANGE subreddit:hot 0 25 - a range scan over an already-sorted structure. No re-sorting at read time.
Feed Cache, per subreddit and sort:
  ZSET  r:programming:hot   { post_9931: 1690.42, post_9930: 1685.11, ... }
  ZSET  r:programming:new   { post_9931: <created_at>, ... }
  ZSET  r:programming:top:day { post_x: <score>, ... }   # windowed

Why this is safe with a fixed epoch: since hot embeds absolute creation time, the ranking of two posts relative to each other only changes when one of their scores changes, never merely because time passed. A post naturally sinks not because we recompute it but because newer posts get inserted with a larger recency term above it. So the ZSET stays correct with only event-driven updates. This is the trick that makes real-time hot ranking affordable at 50M DAU.

top with time windows: top:day/week/month cannot use the decayed hot score - it is pure score within a window. Maintain separate ZSETs keyed by window, and expire entries out of the day/week windows as they age. top:all is a single global-by-subreddit ZSET on raw score. new is a ZSET scored by created_at (or just post_id, since Snowflake IDs are time-sortable), needing no vote updates at all.

The front page (aggregate of a user’s subscribed subreddits) is a merge of the top slices of each subscribed subreddit’s hot ZSET, done at read time and cached briefly per user, or precomputed for very active users - the same fan-out trade-off as any feed system, but bounded because we only merge the top ~25 from each of a user’s subscriptions.

3. The comment tree

The naive version: store each comment with a parent_id, and to render a post’s comments, recursively query children level by level.

-- level 0
SELECT * FROM comments WHERE post_id = ? AND parent_id IS NULL;
-- then for each of those, its children, then their children...

Where it breaks: a viral post has 40,000 comments nested a dozen levels deep. Recursive per-level queries are N+1 query explosions - one query per node to get its children, thousands of round trips to render one page. And you cannot sort a nested tree by best with naive per-level queries because a highly-voted deep reply needs to influence where its subtree renders. This does not fit in one query and it does not fit on one screen.

Fix, in two parts: fetch the tree in one shot, and paginate the tree, not the rows.

Storing and fetching the tree. Store comments flat with post_id, parent_id, and a materialized path (the sequence of ancestor IDs, e.g. 9931/10022/10088). The path lets you fetch and order an entire subtree with a single indexed range query instead of recursive descent:

Table: comments
  comment_id  BIGINT   PRIMARY KEY   -- Snowflake, time-sortable
  post_id     BIGINT                 -- partition/shard key
  parent_id   BIGINT   NULL
  path        STRING                 -- "9931/10022/10088", indexed
  author_id   BIGINT
  body        STRING
  score       INT                    -- denormalized from Score Store
  created_at  TIMESTAMP

  Shard by: post_id  (all comments for one post live together)
  Index on: (post_id, path)   -- fetch/sort an entire subtree in one range scan

All comments for a post live on one shard (sharded by post_id), so loading them is a single-partition read, not a scatter-gather. ORDER BY path reconstructs the tree in DFS order in one query; the application then folds it into a nested structure in memory.

Paginating the tree. You never load all 40,000 comments. Load the top-level comments sorted by the chosen order (best/top/new), and for each, only its first few levels and first few replies. Deeper or lower-ranked subtrees are replaced by a “load more comments” / “continue this thread” token that lazily fetches that subtree on demand - exactly what real Reddit does. So a page is: top ~200 comments by rank, each with a shallow slice of its subtree, and expansion tokens everywhere else.

Sorting comments by best. best is not raw score - a comment with 1 upvote and 0 downvotes should not outrank one with 200 up and 50 down. Reddit uses the Wilson score confidence interval (the lower bound of a 95% confidence interval on the true upvote proportion). It rewards a high proportion of upvotes weighted by how many votes have been cast, so a 200/250 comment beats a 1/1 comment. Comment scores flow from the same Vote Aggregator; the aggregator recomputes the Wilson best score on vote change and the render step orders siblings by it.

best(ups, downs):
    n = ups + downs
    if n == 0: return 0
    p = ups / n
    z = 1.96                                   # 95% confidence
    # Wilson lower bound
    return (p + z*z/(2n) - z*sqrt((p*(1-p) + z*z/(4n))/n)) / (1 + z*z/n)

controversial sorts by items with high total volume but a near-even up/down split. new sorts by created_at. All are cheap because comment counts come pre-aggregated.

4. Anti-brigading and bot resistance

The naive version: count every vote equally and in real time. Where it breaks: a coordinated ring of 5,000 accounts (bought, or a raid organized off-platform) all upvote one post in 90 seconds. With equal, real-time counting, the post rockets to the top of a subreddit before any human notices, which is precisely the attacker’s goal. Naive counting is defenseless because it treats an organic vote and a brigade vote identically.

Why the async aggregate architecture already helps: because scores are eventually consistent and we control the aggregator, we have a natural chokepoint where we can inspect, delay, weight, or reject votes before they affect rank - something a synchronous score++ never gives you. Defense is layered:

  • Vote velocity anomaly detection (stream analytics). The Abuse Service consumes the raw vote log and watches per-item vote velocity and its composition: a post going from 5 to 5,000 votes in two minutes, where the voters are disproportionately new accounts, share IP subnets or device fingerprints, have no subscription to the subreddit, and arrive in a tight time cluster, is a brigade signature. Organic virality has a different shape (broader account age distribution, referral spread, gradual ramp).
  • Vote weighting, not just blocking. Instead of hard-rejecting suspect votes (which tips off attackers and creates false-positive collateral), the aggregator applies a trust weight per voter when computing the ranking score. A brand-new account, an account with no history in this subreddit, or one flagged by the fraud model contributes a fraction of a vote to hot while still being recorded at face value in the raw log. The displayed count can be “fuzzed” and the ranking score is the trust-weighted one. Legitimate users are unaffected; a ring of throwaways barely moves rank.
  • Rate limiting and proof-of-humanity at the edge. Per-account and per-IP vote rate limits, plus CAPTCHA/device-attestation challenges on suspicious sessions, throttle the mechanical volume a bot ring can generate before it ever reaches the aggregator.
  • Account trust scoring. Every account carries a trust score derived from age, karma, verified email/phone, and historical behavior. This feeds the vote weight directly and is cheap to look up in the aggregator.
  • Delayed finalization / vote fuzzing. Because the score is already eventually consistent, we can hold a small settling window on rapidly-moving posts, during which the Abuse Service can down-weight a detected ring retroactively before the post crests. The attacker cannot observe the exact real-time effect of their votes, which breaks the feedback loop they rely on.
  • Retroactive recount. Because every raw vote is durably logged (the Vote Store), a confirmed brigade can be reversed by replaying the log with the offending accounts’ votes removed and recomputing the affected scores. The audit log is what makes after-the-fact correction possible.

The combination - a chokepoint aggregator + velocity anomaly detection + trust-weighted (not binary) counting + edge rate limits + a replayable audit log - is what makes voting near-real-time and resistant to manufactured rank. The architectural enabler is that we never made the score a live synchronous sum; the async aggregate is the seam where defense lives.

API Design & Data Schema

API

POST /api/v1/posts
Body:
{
  "subreddit_id": "programming",
  "title": "Materialized paths beat recursive CTEs for comment trees",
  "kind": "text",                         // "text" | "link"
  "body": "...",                          // or "url": "https://..."
}
Response 201:
{ "post_id": "1789324411223300", "subreddit_id": "programming",
  "author_id": "42", "created_at": "2026-07-25T09:00:00Z", "score": 1 }
Errors: 400 invalid, 401 unauthenticated, 403 not a member, 429 rate limited
POST /api/v1/vote
Body: { "item_id": "1789324411223300", "item_type": "post", "value": 1 }
                                                    // value in {1, 0, -1}
Response 202 Accepted:                              // async aggregation
{ "item_id": "...", "your_vote": 1 }                // echoes the user's vote
Errors: 400 bad value, 401 unauthenticated, 429 rate limited / challenged
GET /api/v1/r/{subreddit}?sort=hot&limit=25&cursor=<opaque>
   sort ∈ {hot, new, top}   top adds &t=day|week|month|all
Response 200:
{ "posts": [ { "post_id": "...", "title": "...", "author": {...},
               "score": 1690, "num_comments": 412, "created_at": "...",
               "your_vote": 0 }, ... ],
  "next_cursor": "<opaque>" }
Latency target: p99 < 200ms
GET /api/v1/posts/{post_id}/comments?sort=best&limit=200&cursor=<opaque>
Response 200:
{ "comments": [ { "comment_id": "...", "parent_id": null, "body": "...",
                  "score": 88, "your_vote": 0, "replies": [ ...shallow... ],
                  "more_replies_token": "<opaque>" }, ... ],
  "next_cursor": "<opaque>" }
POST /api/v1/comments      { "post_id": "...", "parent_id": "...", "body": "..." }
POST /api/v1/subreddits    { "name": "...", "description": "..." }
POST /api/v1/subreddits/{id}/subscribe   -> 202

Pagination is cursor-based, not offset-based. For new and comment trees the cursor encodes the last-seen id (Snowflake, time-sortable) so it is stable as new items arrive. For hot/top the cursor encodes the last-seen (hot_score, post_id) position in the ZSET, so paging is a stable range continuation even as scores shift slightly.

Data store choices

Different access patterns, so more than one store. Be explicit.

1. Content Store (posts, comments) - NoSQL wide-column (Cassandra / DynamoDB). Access is point-lookup by id and range scan of “recent posts in a subreddit” or “comments for a post”. No cross-entity ACID transactions, needs horizontal scale to a few TB/year with a high comment write rate. That is the sweet spot for a wide-column store.

Table: posts
  post_id      BIGINT   PARTITION KEY   -- Snowflake, time-sortable
  subreddit_id BIGINT
  author_id    BIGINT
  title/body   STRING
  created_at   TIMESTAMP
  Shard by: post_id      (single-key reads land on one shard)

Table: subreddit_posts   -- "recent posts in r/x", the listing source
  subreddit_id BIGINT   PARTITION KEY
  post_id      BIGINT   CLUSTERING KEY DESC   -- newest first
  Shard by: subreddit_id (range scan of one subreddit's posts)

Table: comments          -- as shown in deep dive 3
  Shard by: post_id, indexed on (post_id, path)

2. Vote Store - append-only log, sharded by item_id (Cassandra / a Kafka-backed log + cold object storage). Writes are append-only, reads are “all votes for item X” (for recount) and “user X’s vote on item Y”. Never updated in place - a vote change is a new record; the latest wins.

Table: votes
  item_id   BIGINT   PARTITION KEY
  user_id   BIGINT   CLUSTERING KEY
  value     TINYINT               -- +1 / 0 / -1 (latest state)
  updated_at TIMESTAMP
  weight    FLOAT                 -- trust weight applied at aggregation
  Shard by: item_id  → "all votes for item" is single-partition (recount)
  Also: user_votes table sharded by user_id for "your_vote" overlays

3. Score / Ranking Store - Redis (ZSETs) backed by a durable counter store. The per-subreddit sorted lists (hot/new/top ZSETs) and per-item {up, down, score, hot} live in Redis for range reads and O(log n) reordering, sharded by subreddit_id (feed lists) and item_id (counters). This is a cache/index: it is rebuildable by replaying the Vote Store, so losing a Redis node loses nothing durable.

4. Subreddit / membership / user metadata - relational (Postgres). This is low-volume, relationship-heavy, and benefits from transactions and joins: subreddit definitions, moderator lists, membership, user accounts, and trust scores. It is small enough for a primary with read replicas, and the one place strong consistency is worth having (creating a subreddit, granting mod rights).

Why NoSQL for content and votes, SQL for metadata: the high-volume, partition-scoped, transaction-free paths (posts, comments, votes) demand horizontal scale and no joins, which is exactly what wide-column stores are for; the low-volume, relational, consistency-sensitive data (subreddits, membership, mod permissions) is exactly what a relational DB is for. Choosing created_at as a shard key anywhere would be the classic mistake - it concentrates all current writes on the “now” shard. We shard by post_id/item_id/subreddit_id, all of which spread load evenly.

Bottlenecks & Scaling

Where it breaks first, in order, and the fix for each:

1. Hot-post vote contention (breaks first and worst). A front-page post takes tens of thousands of votes/minute; a synchronous counter serializes them on one row lock. Fix: decouple the vote record (per-user idempotent key, no contention) from the score, and aggregate votes asynchronously by item_id through Kafka so thousands of votes collapse into one score write per flush. This is the single decision that makes the system possible.

2. Vote ingestion volume (70K votes/sec peak). More writes than feed reads. Fix: a dedicated buffered pipeline (Vote Service → partitioned Kafka → aggregator) rather than direct DB updates. Partitions scale horizontally; aggregators are a scaled, idempotent consumer group. Per-item ordering is preserved by partitioning on item_id.

3. Feed read QPS (52K/sec) and the hot re-sort. Re-sorting every post per read is impossible. Fix: precomputed per-subreddit sorted ZSETs updated event-driven on score change (safe because hot embeds fixed creation time), so a read is a ZREVRANGE. Hydrate IDs with a batched MGET. Sharded by subreddit_id.

4. Comment tree N+1 explosion on viral posts. 40K comments, a dozen levels deep. Fix: materialized path column + single indexed range scan per subtree, comments co-located by post_id shard, and lazy “load more” expansion tokens so a page loads a shallow, ranked slice - never the whole tree.

5. Brigading / bot rings. Coordinated votes manufacture rank. Fix: the async aggregator is a chokepoint for velocity anomaly detection, trust-weighted counting, edge rate limits, delayed finalization, and replay-based retroactive recount from the durable Vote Store. Covered in deep dive 4.

6. Hot read keys (a viral post’s body and score). Millions load the same post at once. Fix: replicate the hottest Content Cache and Score entries across cache nodes (read a random replica), a per-instance local LRU with a few-second TTL, and a CDN for immutable post bodies and media. Post bodies are immutable once created, so they cache aggressively at the edge.

7. Front-page fan-out (merging many subscribed subreddits). Fix: merge only the top ~25 of each subscribed subreddit’s hot ZSET at read time and cache the merged page per user for a few seconds; precompute for the most active users. Bounded because we merge small top-slices, not full lists.

8. Score Store / Redis as a rebuildable index. Fix: treat all ranking data as a cache. On node loss, rebuild the affected ZSETs and counters by replaying the Vote Store (sharded by item_id). Nothing durable is lost because the raw vote log is the source of truth.

9. Single points of failure. Post/Comment/Vote/Feed Services are stateless behind load balancers - scale horizontally, any instance serves any request. Kafka, caches, and stores are replicated (RF 3). Aggregators and abuse consumers are scaled, idempotent consumer groups. No single box whose loss stops feeds from loading or votes from being recorded.

10. Multi-region latency for a global user base. Fix: deploy Feed Services and caches per region, route via GeoDNS. Because scores and rank are eventually consistent and rebuildable, cross-region replication is forgiving - a few seconds of score lag on a far-region feed is invisible. Votes are recorded in the user’s home region and replicated; the raw vote log reconciles any cross-region divergence.

Wrap-Up

The trade-offs that define this design:

  • Decouple the vote record from the score, aggregate asynchronously. We enforce one-user-one-vote synchronously at a per-user idempotent key (no contention) and let the aggregate score settle in batches. We trade a few seconds of score staleness - which nobody notices - for eliminating hot-row contention and turning a firehose of contended updates into one write per post per flush. This is the single most important call in the system.
  • hot as a score over fixed creation time, ranked in precomputed ZSETs. Because hot embeds absolute created_at, a post’s rank changes only when its votes change, so we update sorted lists event-driven and read them as range scans - real-time ranking without re-sorting per request.
  • Materialized paths and lazy expansion for the comment tree. One indexed range scan per subtree plus “load more” tokens beats recursive per-level queries, and Wilson-score best ranks siblings by confidence, not raw count.
  • The async aggregate is the seam where abuse defense lives. Because the score was never a live synchronous sum, we get a natural chokepoint for velocity detection, trust-weighted (not binary) counting, delayed finalization, and replay-based recount from a durable vote log - near-real-time yet resistant to brigading.
  • NoSQL for content and votes, SQL for metadata, everything sharded by the read key. Partition-scoped, transaction-free access at high volume is wide-column’s job; relational, consistency-sensitive subreddit/membership data is Postgres’s job; sharding by item_id/post_id/subreddit_id keeps every hot-path operation a single-partition action.

One-line summary: a read-heavy ranking system over an adversarial vote firehose, where per-user idempotent vote records feed an async, per-item aggregator that trust-weights and combines votes into batched score updates, hot/top/new are served from precomputed sorted ZSETs keyed on fixed creation time, comment trees load as materialized-path range scans with lazy expansion, and a durable vote log makes both real-time ranking and after-the-fact brigade recount possible at 50M DAU.