Build Reddit's Post Ranking Algorithm


data-engineering performance caching

System Design Deep Dive

Reddit's Post Ranking Algorithm

A score that blends confidence-weighted votes with continuous time decay, updated one post at a time instead of resorting an entire feed.

⏱ 16 min read 📐 Advanced 🏗️ Data Engineering

Picture a community corkboard in an office break room, where anyone can pin a flyer and anyone walking past can stick a thumbs-up or thumbs-down note next to it. A flyer pinned an hour ago with twelve thumbs-up probably deserves eye-level placement over a flyer from six days ago with fifteen thumbs-up, because the older one already had a hundred and forty-four hours to collect votes while the newer one earned its votes in a fraction of that time. But a flyer pinned thirty seconds ago with a single thumbs-up should not leapfrog a flyer from two hours ago sitting at eighty net positive votes just because it is newer. Deciding what belongs at eye level, right now, without physically walking over and re-pinning every flyer in the room every time one more sticky note gets added, is the entire problem a ranking algorithm exists to solve.

Now scale that corkboard to a platform where roughly 700,000 posts land every day across millions of active communities, and where those posts collectively receive on the order of 140 million votes a day, a sustained rate near 1,600 votes per second that bursts past 25,000 votes per second during a major live event, a viral thread, or a coordinated brigade. Readers expect a subreddit’s front page to render in well under 200ms at the 99th percentile, and they expect it to feel alive: a post that was buried an hour ago should be able to climb back toward the top if it suddenly catches fire, and a post from yesterday should keep sliding down even if nobody touches it again. Getting this right is not a matter of taste, it is a matter of physics: with millions of live posts and tens of thousands of votes arriving every second, whatever mechanism decides “what shows first” has to do a vanishingly small amount of work per vote, or it collapses under its own denominator.

The naive fix, sort every post in a subreddit by net upvotes at read time, fails on two fronts at once. First, ranking by raw net score treats a post with 1 upvote and 0 downvotes as “100% positive” and ranks it above a post with 950 upvotes and 60 downvotes, even though the second post has vastly more evidence behind its popularity and the first is one enthusiastic click away from being a coin flip. Second, sorting on every page load means re-deriving the order of every post in a subreddit’s history each time anyone requests a feed, which is fine for a subreddit with a few hundred posts and catastrophic for one with tens of millions. The opposite naive fix, freeze a post’s score forever the moment it is created, solves the recompute cost but breaks freshness completely: a post from last year with a huge net score would permanently outrank everything posted today, because nothing is pulling old content back down as time passes.

We need to solve for three things simultaneously: a scoring function that turns raw vote counts into a statistically defensible confidence signal instead of a raw tally, a decay mechanism that lets time erode a post’s rank continuously without a background job rewriting every row in the corpus, and an update path that touches only the single post that just received a vote, in roughly constant time, rather than re-deriving an entire subreddit’s order from scratch.

Requirements and Constraints

Functional Requirements

  • Accept upvote, downvote, and vote-retraction events for a post, keyed by (post_id, user_id), so a user’s vote counts exactly once and can flip direction without double-counting
  • Maintain three distinct orderings per subreddit: hot (time-decayed relevance), top (period-scoped confidence score), and new (pure chronological order)
  • Combine raw ups and downs into a Wilson score lower-bound confidence interval, so posts with few votes cannot outrank posts with far more evidence at a similar or better ratio
  • Apply a continuous time-decay term to the hot score so a post’s rank keeps falling as it ages, even if it receives no further votes at all
  • Compute a separate controversy score per post to power a “controversial” sort and to help flag statistically unusual voting patterns
  • Allow each subreddit to customize its ranking behavior (decay half-life, Wilson confidence level, weight given to comments and awards) through configuration, not a code deploy
  • Update a post’s score incrementally in response to a single vote, touching only that post’s row and index entries, never resorting the rest of the feed
  • Tolerate a bounded amount of score staleness for decay-only drift (no new vote), while guaranteeing a fresh vote is reflected in the index within a tight latency bound

Non-Functional Requirements

  • Scale: roughly 700,000 new posts/day, 140,000,000 votes/day (about 1,600 votes/sec sustained, bursting to 25,000 votes/sec during major events), millions of active subreddits, with a “hot” working set of roughly 50 million actively-ranked posts (last 30 days) sitting on top of a multi-billion post historical corpus queried mainly through top: all time
  • Latency: vote ingestion acknowledgment p99 under 80ms; a vote’s effect on the hot index visible within 500ms p99; a paginated feed page (25 posts, hot/top/new) render p99 under 150ms
  • Score staleness tolerance: a hot score is allowed to drift up to 60 seconds stale relative to pure time decay when no new vote has occurred, but must reflect an actual vote within 500ms
  • Durability: no vote may be silently lost; the vote log is durable and replayable from a fixed offset
  • Availability: the feed read path targets 99.95% uptime; the vote write path targets 99.9%, and is allowed to degrade to “accepted, ranking delayed” before it is allowed to reject votes outright
  • Consistency: the ranking index is eventually consistent by design; the underlying ups/downs counters for a single post are strongly consistent per post row

Constraints and Assumptions

  • Comment content and comment-thread ranking are out of scope; this system ranks posts only, and treats comment_count as an input signal, not something it owns
  • Awarding and gilding are treated as an input weight, not a payment or inventory system; we assume award events arrive as a simple count delta
  • Moderation removal and ban enforcement happen upstream; this design assumes a removed flag is already set correctly on a post before it reaches the ranking layer
  • User authentication and one-vote-per-user eligibility are solved by an existing auth system; this design assumes a valid, deduplicated user_id is attached to every vote
  • Cross-subreddit personalization (a user’s individually tailored home feed, based on subscriptions and past behavior) is out of scope; this design covers subreddit-level and network-level (r/all-style) ranking only

High-Level Architecture

The system has six major components: the Vote Ingestion Gateway, the Vote Log and Dedupe Store, the Score Update Engine, the Subreddit Ranking Config Service, the Ranking Index Store, and the Feed Assembly Service.

Architecture overview showing the vote ingestion gateway, vote log, score update engine with its controversy and anti-spam processor, subreddit ranking config service, ranking index store, and feed assembly service

The Vote Ingestion Gateway is the front door for every upvote, downvote, and retraction: it validates the request and treats every vote as idempotent on (post_id, user_id). The Vote Log and Dedupe Store is the durable, replayable record of every vote, and the source of truth if any downstream index ever needs to be rebuilt. The Score Update Engine is the core of the whole design: it applies a vote’s delta to a post’s ups/downs counters and recomputes that one post’s Wilson score, decayed hot score, and controversy score in roughly constant time, without touching any other post. A companion Controversy and Anti-Spam Processor watches the same vote stream for statistically unusual patterns, both to compute the controversy score and to catch coordinated manipulation. The Subreddit Ranking Config Service holds per-community tuning (decay half-life, Wilson confidence, comment and award weights) so one subreddit’s culture doesn’t have to rank identically to another’s. The Ranking Index Store holds three sorted indices per subreddit, one each for hot, top, and new, and is the only thing a feed read ever touches. The Feed Assembly Service paginates those indices, merges across subreddit shards for network-wide views, and is what actually answers “give me the next 25 posts.”

A vote’s life looks like this: a reader clicks upvote, the gateway checks the vote against its (post_id, user_id) key so a network retry never double-counts, and on success the vote is appended to the durable log. The Score Update Engine picks up that event, applies the delta to the post’s counters, and recomputes the Wilson score, hot score, and controversy score for that single post, using per-subreddit parameters pulled from the config service. The updated scores are written into the Ranking Index Store’s hot, top, and controversial sorted sets with a single atomic operation, and the Feed Assembly Service’s next read for that subreddit picks up the new ordering immediately. Independent of any vote at all, a bucketed decay-refresh sweep periodically re-touches recently active posts so the hot score’s time-decay term keeps eroding correctly even for posts that stop receiving votes, bounded by the staleness tolerance from the requirements above.

Key Insight

The single most important architectural decision is that a vote triggers a recompute of exactly one post's score, never a resort of the feed it belongs to. A design that resorted a subreddit's whole post list on every vote would be O(n log n) in the size of that subreddit's history for every single vote, and at 1,600 votes a second sustained, that cost compounds into an unrecoverable backlog within minutes. Keeping the unit of work at "one post" is what makes the whole system linearly scalable in vote volume instead of quadratic in corpus size.

Component Deep Dives

The Vote Ingestion Gateway

This component’s job is to make sure the exact same vote, retried because of a flaky mobile connection or a client that never received its response, is never counted twice.

The obvious mistake is trusting a client to send a vote exactly once. Mobile clients drop connections mid-request constantly, and a client that times out waiting for an acknowledgment has no way to know whether the server actually processed the vote before the connection died, so a correct client retries, and the server has to treat that retry as harmless. We key every vote by (post_id, user_id) and treat the operation as an upsert of direction, not an append-only insert of an event.

-- Vote upsert: a user's vote on a post is a single row that can change
-- direction, never a growing list of separate vote events for the same pair
INSERT INTO votes (post_id, user_id, direction, cast_at)
VALUES ($1, $2, $3, NOW())
ON CONFLICT (post_id, user_id)
DO UPDATE SET direction = EXCLUDED.direction, cast_at = EXCLUDED.cast_at
RETURNING (
    SELECT direction FROM votes WHERE post_id = $1 AND user_id = $2
) AS previous_direction;

Think of this like a light switch rather than a tally counter: flipping the same switch to “on” five times in a row leaves the room exactly as lit as flipping it once, because the switch has one state, not a running count of flips. What breaks without this: a shopper on a spotty connection taps upvote once, the request goes through, the acknowledgment is lost, the client retries, and a naive counter-increment design counts two upvotes for one click, quietly inflating a post’s score for votes that were never actually cast twice by a human.

Premium Content

Unlock the full article along with everything else in the archive — all in one place.

In-depth analysis Expert insights Full archive access
Unlock Full Article