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.
Write flow, traced: creating a post
User A submits a link post to r/systems. The write path never blocks on fan-out:
- Gateway.
POST /postshits the API gateway, which authenticates A's token and routes to the Post Service. - 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. - Initial rank. Because the time term depends only on
created_utc(see below), the Post Service computes the post'shotscore right here, once, and stores it. It will never need recomputing as the clock advances. - 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 returns201 Createdto A in a few milliseconds. - 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:
- Reply. User B replies to comment C42 on post P.
POST /posts/P/commentswithparent_comment_id = 42writes a row keyed to P's partition, bumps the denormalizedcomment_counton the post, and publishesCommentAdded{post_id:P, parent:42, author:B}for the notification service. - Read the tree.
GET /posts/P/commentsissues 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". - 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:
- Freshness without decay. Old posts do not lose points over time — the score has no
nowin it. Newer posts simply start higher. That is what keeps the feed fresh. - Computed once, forever stable. Because
tis fixed at submission and votes only nudge the tinyorderterm, a post'shotis computed at creation and updated only when votes change — never on a timer, never per read. That is why the write flow above can rank the post inline. - 45000 s ≈ 12.5 h. Every 12.5 hours of newer creation adds exactly
+1to the time term. Sinceorderis a base-10 log,+1equals one order of magnitude — 10× the net votes. So a post made 12.5 h later needs only one-tenth the votes to tie.
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.
Worked example: computing a hot score
Take Post P: 300 upvotes, 100 downvotes, created at created_utc = 1,780,000,000 (late May 2026).
s = 300 - 100 = 200order = log10(max(200,1)) = log10(200) = 2.3010300sign = +1(s > 0)t = 1,780,000,000 - 1,134,028,003 = 645,971,997t / 45000 = 14,354.9332667hot = 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:
t = 645,971,997 + 45,000 = 646,016,997, sot/45000 = 14,355.9332667hot = 2.3010300 + 14,355.9332667 = 14358.2342967hot(Q) - hot(P) = 1.0000000— Q wins by exactly one time-unit.
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.
- 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 byuser_idso 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 apost_id-keyed secondary index or a change-log copy rather than scanning every user shard.) - 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 == newand emits no delta — that, not client good behavior, is what makes double-votes harmless. - 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: oneUPDATE 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. - Recompute hot on flush. Each flush recomputes
hotfrom the new net score plus the storedcreated_utc(onelog10— 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:
- Traffic. 50M DAU. New posts ~1M/day ≈ 12/sec — post writes are trivial. Votes ~100M/day ≈ 1.2k/sec average, ~10k/sec peak — absorbed by the buffered vote path above. Feed loads ~10 per DAU/day = 500M/day ≈ 6k/sec average, ~30k/sec peak — the read side that push exists to make O(1).
- Feed memory. Cap each materialized feed at ~500 entries. A Redis ZSET entry (8-byte id + 8-byte score + skiplist/dict overhead) runs ~100 B, so ~50 KB per user; materializing only the ~50M recently-active users gives 50M × 50 KB ≈ 2.5 TB — a sharded fleet of ~25 nodes at 100 GB each, which is precisely why dormant users are left pull-only.
- Deriving T. The push/pull threshold is not taste; it falls out of the fan-out SLO. If one feed worker sustains ~50k ZADDs/sec and we run 10 workers against a “post visible in ≤5s” SLO, the largest audience push can serve is 50k × 10 × 5s = 2.5M subscribers. Communities above that are marked pull-only. Faster workers or a looser SLO move T — the formula, not the number, is the takeaway.
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:
| Model | Write cost (per post) | Read cost (per feed load) | Best when | Worst when |
|---|---|---|---|---|
| Push (fan-out on write) | O(subscribers) feed writes | O(1) — read a pre-merged list | read-heavy, modest fan-out, active readers | million-subscriber producers (write storm); inactive readers (materialized work no one reads) |
| Pull (fan-out on read) | O(1) — one row write | O(sources) query + merge every load | huge fan-out; rarely-reading users | latency-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.
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:
- If
F ≤ T→ push. On a new post, feed workers insert(post_id, hot)into each subscriber's sorted-set feed. Reads areO(1). - If
F > T→ mark the producer pull-only. Its posts are not fanned out. They stay in the per-subreddit sorted set and are fetched live. - At read time, merge. The Feed Service reads the user's materialized (pushed) feed, then live-pulls the top-K recent posts from just the handful of pull-only sources that specific user follows, and merges everything by
hot. A user follows only a few mega-communities, so the merge fan-in stays small.
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
- Amir Salihefendic, "How Reddit ranking algorithms work" — derivation of the
hotformula, the 45000-second (12.5 h) constant, and the 1134028003 epoch anchor. - Reddit's open-sourced ranking code (
_sorts.pyx/hot()) — canonical reference forsign · order + seconds/45000using absolute creation time. - Grokking the System Design Interview — "Designing Reddit / Facebook Newsfeed": fan-out-on-write vs fan-out-on-read and the hybrid celebrity fallback.
- Original lesson page, corrected: the ranking formula's
post_ageterm is replaced with the absolutecreated_utcterm, and the truncated feed section is completed with the hybrid decision rule and pull fallback.
🪜 Drill ladder: Designing Reddit (medium)
- 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.) - 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.)
- 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. - 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.)
- 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.
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.
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.
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.
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.