CMD Guide
HomeSystem DesignSystem Design Problems

medium Designing Reddit

The two correctness-critical pieces

A Reddit-style backend is read-heavy and rank-driven, so two decisions carry most of the design's weight and are the easiest to get subtly wrong: how a post's ranking score is computed, and how a post reaches every subscriber's feed. This rewrite traces both end-to-end with worked numbers, and fixes a bug in the ranking formula carried by the original page.

The original stated Reddit's "hot" score as log10(|ups - downs|) + sign(v) · (post_age_in_seconds / 45000). That is wrong in two ways. First, it adds a positive multiple of post_age, so an older post scores higher — the opposite of keeping a feed fresh. Second, post_age = now - created is measured from the current clock, so every post's score changes on every read and the ordering is never stable. Reddit's real algorithm instead uses the post's absolute creation time as the time term, computed once at submission. The corrected formula and a full trace are in the ranking section below.

diagram
diagram

Write flow, traced: creating a post

User A submits a link post to r/systems. The write path never blocks on fan-out:

  1. Gateway. POST /posts hits the API gateway, which authenticates A's token and routes to the Post Service.
  2. Persist. The Post Service mints a globally unique post_id (Snowflake), then writes one row to the sharded posts store — partitioned so a subreddit's posts co-locate, e.g. primary key (subreddit_id, post_id) in Cassandra. Denormalized fields (score, comment_count, author username) are stored inline to avoid cross-shard joins on read.
  3. Initial rank. Because the time term depends only on created_utc (see below), the Post Service computes the post's hot score right here, once, and stores it. It will never need recomputing as the clock advances.
  4. Enqueue fan-out. The service does not synchronously write into subscribers' feeds. It publishes a PostCreated{post_id, subreddit_id, hot} job to a fan-out queue and returns 201 Created to A in a few milliseconds.
  5. Async materialize. Feed workers consume the job and, for push-eligible subreddits, insert (post_id, hot) into each subscriber's feed sorted set. This work happens off the request path, so a slow fan-out never slows the post button.

Comment threading, traced

Comments form arbitrarily deep trees, so we store them as an adjacency list: each comment row carries a parent_comment_id (NULL for a top-level comment) and the owning post_id. Crucially, comments are sharded by post_id, so one post's entire thread lives on one partition — the tree is fetched without scatter-gather.

Tracing a reply and a thread read:

  1. Reply. User B replies to comment C42 on post P. POST /posts/P/comments with parent_comment_id = 42 writes a row keyed to P's partition, bumps the denormalized comment_count on the post, and publishes CommentAdded{post_id:P, parent:42, author:B} for the notification service.
  2. Read the tree. GET /posts/P/comments issues a single partition read for all of P's comments, then rebuilds the tree in memory by linking each comment to its parent. Ordering within a sibling group reuses the same score-based rule the feed uses (top replies first), or chronological for "new".
  3. Hot threads. A post with tens of thousands of comments still reads from one partition; the top-level comments are cached separately so the common "first screen" render avoids materializing the whole tree.

Ranking: the corrected "hot" formula

The flagged bug is here. Reddit ranks a post by its net votes and its age, but the time term is the post's absolute creation timestamp, not an elapsed age. The published algorithm:

s     = ups - downs
order = log10(max(|s|, 1))
sign  = +1 if s > 0 else (-1 if s < 0 else 0)
t     = created_utc_seconds - 1134028003     # seconds since Reddit's epoch anchor (Dec 8 2005)
hot   = round(sign * order + t / 45000, 7)

Read t carefully: it is seconds-since-epoch of the post's creation. A newer post has a larger t and therefore a higher hot score. Two consequences drive the whole design:

Why the original was wrong: it used post_age = now - created with a + sign, which (a) makes older posts rank higher, backwards from the intent, and (b) makes every score a function of the current clock, forcing a full recompute of every post on every read and yielding an ordering that shifts continuously. Swapping the relative post_age for the absolute created_utc term fixes both.

diagram
diagram

Worked example: computing a hot score

Take Post P: 300 upvotes, 100 downvotes, created at created_utc = 1,780,000,000 (late May 2026).

  1. s = 300 - 100 = 200
  2. order = log10(max(200,1)) = log10(200) = 2.3010300
  3. sign = +1 (s > 0)
  4. t = 1,780,000,000 - 1,134,028,003 = 645,971,997
  5. t / 45000 = 14,354.9332667
  6. hot = round(1 × 2.3010300 + 14,354.9332667, 7) = 14357.2342967

Notice the time term (14,354.9) dwarfs the vote term (2.3). That is expected and fine: all posts being ranked share a nearly identical time magnitude, so what decides order at the margin is the difference in creation time plus the vote term.

Now Post Q — identical votes (300/100) but created 12.5 h later, created_utc = 1,780,045,000:

For the older Post P to tie Q, its order must rise by 1, i.e. log10(s_P) = 3.30103, so s_P = 10^{3.30103} = 2000. P needs a net 2000 votes to match Q's 200 — the promised 10×. This single arithmetic fact is the whole judgment layer of the ranking: a post has roughly a 12.5-hour window to gather each additional order of magnitude of votes before newer content structurally out-ranks it.

The vote write path, traced

Votes are the only input that ever changes a hot score, so the vote path deserves the same rigor as the formula. Two traps live here: the same user's vote arriving twice (a retry, or two devices racing), and a viral post taking thousands of vote writes per second against its one denormalized score row.

  1. One row per (user, post). Votes get their own table: votes(user_id, post_id, direction) with (user_id, post_id) as the primary key, sharded by user_id so a user's own votes are a point lookup. The unique key is the idempotency guard — there is no counter to inflate, only a per-user state to set. (The reconciliation job's per-post recompute cuts across this sharding, so it reads a post_id-keyed secondary index or a change-log copy rather than scanning every user shard.)
  2. Delta from the transition, not the request. The write is a conditional upsert that returns the prior direction, and the score delta is derived from the transition: none→up = +1, up→none = −1, down→up = +2, and up→up (a duplicate or replayed request) = 0. A retry finds prev == new and emits no delta — that, not client good behavior, is what makes double-votes harmless.
  3. Buffer the hot row. Deltas are not applied to the posts row one by one. They accumulate (e.g. Redis INCRBY score_delta:{post_id}) and a flusher applies each active post's net delta every 1–10s: one UPDATE posts SET score = score + Δ per window. A viral post taking 5,000 votes/sec now costs its score row ~0.1–1 write/sec instead of 5,000 — the hot-row write storm collapses to O(1) flushes.
  4. Recompute hot on flush. Each flush recomputes hot from the new net score plus the stored created_utc (one log10 — cheap) and updates the per-subreddit sorted set that pull reads use. The time term never changes, so this is the only recompute the design ever needs.

The price of buffering is 1–10s of score staleness — invisible at ranking granularity, and strictly better than lock contention on the row. And because a crashed flusher can drop a buffered delta, a periodic reconciliation recomputes each hot post's score from the votes table: the per-user vote rows are the source of truth; the denormalized score is a derived cache.

Capacity sketch — and deriving the threshold T

Enough numbers to size the feed machinery, one consistent set:

Feed delivery: push vs pull, with costs

Delivering feeds is the second correctness-critical choice. Two pure strategies sit at opposite ends of the write/read trade-off:

ModelWrite cost (per post)Read cost (per feed load)Best whenWorst when
Push (fan-out on write)O(subscribers) feed writesO(1) — read a pre-merged listread-heavy, modest fan-out, active readersmillion-subscriber producers (write storm); inactive readers (materialized work no one reads)
Pull (fan-out on read)O(1) — one row writeO(sources) query + merge every loadhuge fan-out; rarely-reading userslatency-sensitive default feed; users following many sources

Push makes reads trivial but pays a write proportional to the audience; pull makes writes trivial but pays a merge on every read. Neither alone survives Reddit's shape, where most communities are small but a handful (r/all, r/pics) have tens of millions of subscribers.

diagram
diagram

The hybrid decision rule and the celebrity fallback

The design is neither pure push nor pure pull — it is a hybrid keyed on a producer's fan-out. Let F be a subreddit's subscriber count (or, for user-follows, a user's follower count) and T the tuned threshold derived in the capacity sketch above (≈2.5M at our worker fleet and 5s fan-out SLO).

The rule:

Why this beats either pure model — the celebrity problem. A post to r/pics with 30M subscribers under pure push would trigger 30M feed writes, in a burst, most landing in feeds no one will read soon. Marking r/pics pull-only makes that post a single write, pulled on demand only by users who actually load a feed containing it. Pure pull, meanwhile, would force even a user's small-subreddit posts to be re-merged on every read; pushing those keeps the common case O(1).

Pushed scores freeze at creation — and the merge must account for it. Feed workers insert (post_id, hot) once, at post time; but hot moves whenever votes change, and nothing re-fans-out those updates. Left alone, the merge would compare live scores from pull-only sources against creation-time scores frozen in the subscriber ZSETs, systematically burying pushed posts that rose after fan-out. The cheap repair is read-time re-ranking: treat the ZSET as a preselector — fetch the top-K candidates by their stored score, then re-rank that page in memory with live scores from the posts/score cache before merging. That costs O(K) cache lookups per feed load and zero extra writes. The alternative — re-fanning-out a ZADD to every subscriber whenever a score flush changes hot — re-creates exactly the write storm the hybrid exists to avoid; a middle ground is to re-fan-out only when hot crosses a coarse bucket boundary. We take read-time re-ranking as the default.

When to use push / when not. Use push for the common case: modest fan-out, read-heavy, active readers who load feeds often (materialized work is amortized over many reads). Do not push for producers above the threshold (write amplification / bursts) or for dormant readers whose feeds would be materialized and never read — a last-active heuristic can suppress fan-out to inactive users and let them pull on return.

When to use pull / when not. Use pull for very-high-fan-out producers and for the long tail of inactive users. Do not make pull the default for active users' primary feed: an O(sources) merge on every load blows the low-latency (P99 < ~100 ms) target once a user follows many communities. The hybrid resolves this by pushing the many small sources and pulling only the few huge ones.

Sources

🪜 Drill ladder: Designing Reddit (medium)

  1. Compute a hot score. A post has 1,500 up / 300 down, created_utc = 1,780,090,000. Work every step. (s = 1200, order = 3.0791812, t = 646,061,997, t/45000 = 14,356.9332667, hot = 14,360.0124479.)
  2. The 25-hour handicap. Post B is created 25 h after Post A; both sit at net +400. How many net votes does A need to tie B? (25 h = 90,000 s = two time units → A's order must rise by 2 → 100× → 40,000 net votes.)
  3. Race the vote upsert. One device sends “unvote” while another sends “upvote” for the same (user_id, post_id). Trace both interleavings through the conditional upsert's prev→new transitions and give each final score delta — then explain why a bare last-write-wins upsert with no prev-state read can corrupt the count.
  4. Celebrity arithmetic. r/pics has 30M subscribers and T = 2.5M. Count the feed writes one post causes under pure push versus the hybrid, and name the read-time cost the hybrid charges instead. (30M ZADDs vs 1 sorted-set insert; readers live-pull top-K from their few pull-only sources.)
  5. The frozen score. A pushed post gains 10,000 net votes an hour after fan-out. What score does every subscriber's ZSET still hold, and which repair keeps the merge honest without re-fanning-out? (Its creation-time hot; read-time re-ranking of the fetched top-K page with live scores.)
🤖 Don't fully get this? Learn it with Claude

Stuck on Designing Reddit? Open Claude, copy a block below, and it'll teach you this exact concept — visually and interactively.

🪜 Hint ladder (no spoilers)

Progressively stronger hints — you still solve it.

I'm working on the problem **Designing Reddit** (System Design). Give me a HINT LADDER: start with the tiniest nudge, then wait. Only reveal the next, stronger hint when I ask. Do NOT show the full solution unless I type 'show solution'. Keep me doing the thinking. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
🎨 Explain the approach visually

See the technique, not just code.

Explain the optimal approach to **Designing Reddit** with a VISUAL walkthrough: trace it on a small concrete example using ASCII art / a step-by-step diagram, narrate what changes each step, then give time & space complexity with a one-line derivation. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
🔍 Review my solution

Catch bugs, edge cases, sub-optimality.

I'll paste my solution to **Designing Reddit**. Review it for correctness, missed edge cases, and time/space complexity, then coach me toward the optimal — don't just rewrite it. Ask me to paste my code now. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
🔁 Drill the pattern

Lock in recognition with look-alikes.

Give me 2 problems that use the SAME underlying pattern as **Designing Reddit**. For each, let me attempt first, then review my answer and name the trigger signal that reveals the pattern. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.

📝 My notes