Feed ranking systems decide which fresh items deserve scarce attention. Unlike static search ranking, a social or editorial feed must balance popularity, recency, quality controls, and resistance to gaming. A pure vote count quickly becomes stale because yesterday’s winner remains on top. A pure chronological feed ignores quality and overwhelms users when submission volume rises. Time-decay ranking solves the basic freshness problem by making every item lose score as it ages unless new engagement offsets the decay.
The public Hacker News formula is a compact example: rank is driven by a sublinear vote term, a power-law age denominator, and one or more multiplicative factors for moderation or content class. Ken Shirriff’s analysis of front-page rankings showed that the simple formula explains much of the ordering, but that operational ranking also depends on penalties, delayed reranking, and cached pages. That combination is the useful engineering lesson: a production feed is not one formula, but a score function plus a refresh strategy plus guardrails.
This topic teaches the transferable pattern behind those systems. You will learn how gravity controls freshness, why votes usually need diminishing returns, how multiplicative penalties encode policy without rewriting the score function, and how to implement incremental reranking without rescoring the entire corpus on every page view. The same design applies to community forums, internal knowledge feeds, marketplace listings, activity streams, and curated search result modules.
Time-decay ranking normally computes score as engagement divided by a growing function of age. In the Hacker News-style formula, age is measured in hours, shifted by a small base value, and raised to a gravity exponent. Increasing gravity makes the feed turn over faster; decreasing it lets high-quality older items remain visible longer. The shift prevents newborn items from exploding to infinity when their age is close to zero.
Raw votes often follow a rich-get-richer curve: early exposure creates more votes, which creates more exposure. Applying an exponent below one, or a logarithm, makes the difference between 10 and 20 votes matter more than the difference between 1,000 and 1,010 votes. This preserves the signal that many users approved an item while reducing the ability of a single viral item to occupy the top position indefinitely.
A penalty is a multiplier applied after the base score. It can represent spam flags, excessive controversy, duplicate submissions, unsupported item types, low-quality domains, or manual moderation. Multipliers are attractive because they compose: a feed can apply a policy factor, a trust factor, and a content-class factor without changing the core freshness formula.
A feed ranker is a feedback loop. Ranking creates exposure, exposure creates engagement, engagement changes rank, and rank changes future exposure. Time decay and penalties are not cosmetic tweaks; they are stabilizers that keep feedback from collapsing into stale winners, flamewars, spam campaigns, or one-source domination.
Start with a deterministic score that combines votes and age. The exact constants should be product-specific, but a Hacker News-style score is useful because each term has a clear purpose: vote count rewards approval, the vote exponent dampens runaway popularity, the age shift handles newborn items, and gravity controls turnover speed.
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timezone
from math import pow
@dataclass(frozen=True)
class FeedItem:
id: str
upvotes: int
submitted_at: datetime
penalty: float = 1.0
def hours_old(item: FeedItem, now: datetime) -> float:
delta = now.astimezone(timezone.utc) - item.submitted_at.astimezone(timezone.utc)
return max(delta.total_seconds() / 3600.0, 0.0)
def hot_score(
item: FeedItem,
now: datetime,
*,
gravity: float = 1.8,
timebase_hours: float = 2.0,
vote_exponent: float = 0.8,
) -> float:
base_votes = max(item.upvotes - 1, 0)
engagement = pow(base_votes, vote_exponent)
age = hours_old(item, now) + timebase_hours
return (engagement / pow(age, gravity)) * item.penalty
This score is intentionally simple enough to inspect. If two items have the same vote count, the newer one wins. If two items have the same age, the item with more votes wins, but each additional vote contributes less than the previous one. If an item receives a penalty of 0.4, it must earn substantially more votes to compete with an unpenalized item.
Keep the base formula separate from policy. This makes ranking explainable and testable: one unit test can verify the math, another can verify that a flagged item receives the expected multiplier. A real system should log which policy factors were applied, but avoid exposing all internals if doing so would make abuse easier.
def penalty_factor(item: dict) -> float:
factor = 1.0
if item.get("is_duplicate"):
factor *= 0.35
if item.get("spam_flags", 0) >= 3:
factor *= 0.05
if item.get("comment_count", 0) > 40 and item["comment_count"] > item["upvotes"]:
factor *= 0.25
if item.get("source_trust") == "low":
factor *= 0.6
return factor
A naive implementation rescans every item whenever a user opens the feed. That is correct for small systems but wasteful at scale. Hacker News historically reranked an item when it received a vote and periodically refreshed a sample of top stories. The general pattern is to update the item that changed, then run scheduled decay refreshes over the visible candidate set so old items do not get stuck above fresher competitors.
Forums, Q&A sites, link aggregators, and internal communities need a front page that changes throughout the day. Time-decay ranking lets new submissions get a chance while still allowing unusually strong items to stay visible. Penalties can suppress spam, duplicate links, excessive argument loops, or content that violates community rules without deleting the item outright.
Marketplaces often mix freshness with engagement: newly listed products need exposure, but high-performing items should not disappear instantly. A decayed score can combine saves, purchases, reviews, and recency. Multipliers can account for inventory status, seller trust, policy violations, or category diversity.
Internal search products often include a “what changed recently” module beside regular query results. Time decay is useful for ranking new documents, pull requests, incidents, or support articles by a blend of recency and organizational engagement. Unlike personalized ML rankers, a transparent decay formula is easy to debug and explain to stakeholders.
Most feed rankers do not need to score every historical item. Keep a candidate window such as “items submitted in the last 48 hours plus pinned exceptions.” Store the current score in a sorted set or ranking table, and recompute items when votes, flags, comments, or policy fields change. A periodic job can refresh the top window to account for time decay even when no new votes arrive.
def refresh_rankings(store, now: datetime, limit: int = 500) -> None:
candidates = store.recent_or_visible_items(limit=limit)
for raw in candidates:
item = FeedItem(
id=raw["id"],
upvotes=raw["upvotes"],
submitted_at=raw["submitted_at"],
penalty=penalty_factor(raw),
)
store.update_score(item.id, hot_score(item, now))
store.trim_ranked_set(max_items=limit)
Do not tune gravity by taste alone. Replay historical events through several candidate formulas and compare the resulting timelines. Useful inspection outputs include median front-page age, turnover per hour, source diversity, percentage of items with no exposure, and the share of impressions captured by the top item. A small replay harness catches failures that a single example score cannot reveal.
Eligibility answers whether an item may appear at all; ranking answers where it appears among eligible items. Spam, legal takedowns, blocked users, and access-control failures belong in the eligibility layer. Softer quality concerns belong in ranking as penalties. Keeping the boundary explicit prevents a moderation multiplier from accidentally leaking private or disallowed items.