You Probably Don't Need AI to Rank Your Feed
- ranking-algorithms
- system-design
- machine-learning
- TYPESCRIPT
- DATABASE
The itch
At 19 I was building a small social network. It worked, in the sense that posts were stored and displayed. But the feed was dead.
Everything appeared in reverse chronological order, which meant a post someone had spent an hour on scrolled past at exactly the same speed as a one-word comment. Open the app twice in a day and you saw the same thing twice. There was no sense that anything was happening.
What I wanted was a feed that felt alive: recent content near the top, but not so aggressively that something genuinely good vanished after an afternoon. I wanted the app to have an opinion about what mattered.
That is a ranking problem, and I did not know the field had a name. So I sat down and tried to derive something.
What I built
The reasoning went like this. Two forces compete: how much people engaged with a post, and how long ago it was published. Rank by engagement alone and the feed freezes — the same popular posts stay on top forever. Rank by date alone and quality is invisible. So divide one by the other.
score = Σ properties / (1 − e^(−t/stretch) + startValue)
The numerator was a weighted sum of signals: likes, comments, audience size. The denominator was an exponential that started near zero at publication and grew towards a constant. A brand-new post divided by a tiny number and shot to the top; as it aged the denominator grew and pulled it back down.
stretch controlled how fast that happened. startValue kept the denominator
from being exactly zero at t = 0.
I published it to npm as a TypeScript package, wrote a README explaining the
maths, plotted the curve for three values of stretch, and moved on to other
things. The original is still up, warts and all:
github.com/youssefmahersi/RankingAlgorithm.
What I know now that I didn't then
I came back to it recently intending to fix the packaging. I ended up rewriting the whole thing, because the moment I actually analysed my own function I found three defects — none of which I had noticed in four years.
The decay stops. Take the limit as t → ∞. The denominator tends to
1 + startValue ≈ 1, so the score converges to the raw engagement sum. Past a
certain age, time stops mattering entirely and the algorithm is just sorting by
likes. Two posts with equal engagement — one a day old, one a year old — score
identically. My README claimed the opposite.
The tuning knob tuned nothing. With startValue = 0.0002 the freshness
multiplier is 5000× at publication, but it drops below 10× by
t = 0.1 × stretch. Roughly 99.8% of the dynamic range is consumed in the first
tenth of the window; the rest is a plateau. So stretch, the parameter I had
documented as the main control, governed the length of the flat part. The real
behaviour depended on the product startValue × stretch, a quantity that
appeared nowhere in my API.
New posts could never start. If the engagement sum was zero, the score was zero — multiplying zero by 5000 still gives zero. A fresh post with no likes ranked below every old post with a single like. It was never shown, so it never got engagement, so it was never shown. The freshness boost I had built the whole thing around could not fire on the items it existed to help. This is the most predictable failure in ranking and I walked into it, because I tested with seeded data where everything already had engagement.
One redeeming discovery: expand the exponential to first order and
1 − e^(−t/s) ≈ t/s, which makes the whole thing P·s / (t + v·s) — the Hacker
News formula with gravity one. I had reconstructed a known good form without ever
having read it. The intuition about the shape was right. What was missing was the
habit of interrogating it.
What the rewrite is for
The v2 formula is mostly subtraction:
score = log₁₀(1 + Σ wᵢpᵢ) − g · log₁₀(t + t₀)
A power law instead of an exponential, so the decay never flattens. A logarithm
on the numerator, because engagement is power-law distributed and the
five-hundredth like carries far less information than the first. A 1 + inside
it so an empty item scores a finite zero and stays rankable. And a grace window
t₀ during which the age penalty is negligible, which is what actually fixes cold
start.
Two parameters, both answerable in product terms. t₀ is "how long does content
get to start?" g is "how much more engagement must an older item have to
compete?" That second one inverts directly:
g = log(ratio) / log((X + t₀) / t₀)
Which makes the use cases concrete rather than a matter of taste:
- Social feed —
g = 1.5,t₀ = 2 h. A 24-hour-old post needs about 47× the engagement of a fresh one to tie, 130× at 48 hours. Content is effectively dead in two days, which is what a feed should do. - E-commerce —
g = 0.3,t₀ = 72 h. A 90-day-old product needs only 2.8× the sales of a new arrival. Over a full year the penalty reaches roughly 4×, so a real best-seller stays visible for years while still being separable by age. - Forums and marketplaces sit between the two.
And its limits
Worth being explicit, because a library that oversells its scope is worse than one that admits its edges.
It has no idea who you are. There is no personalisation, no per-user signal, no notion that you like woodworking and not football. Everyone sees the same ordering.
It cannot read content. A well-written post and a spam post with identical engagement rank identically. Quality is inferred entirely from behaviour.
It cannot discover anything. If a post never gets its first interaction, t₀ buys
it a window and nothing more. There is no semantic similarity to surface it to
the people who would care.
And it does not scale by itself. A score that decays with age changes every second for every row simultaneously, so it cannot be stored in an indexed column without care. Either you use a form that is linear in time — which is exactly why Reddit's is, since then "now" cancels when comparing two rows and the ordering is permanently fixed and indexable — or you pre-filter to a bounded candidate set and rank that in memory.
The part I actually want to argue
In 2026 the reflex for any ranking problem is embeddings plus an LLM re-ranker. That reflex is often wrong, and it is worth saying why plainly.
Reddit ranked millions of posts for years with one line of arithmetic:
log10(votes) + seconds/45000. Hacker News runs on a formula you can fit in a
tweet plus a cache. These are not the impoverished versions of what Google does.
They are choices made by people who could afford to do something else.
The costs of the ML path are real and mostly ignored at the decision point. Inference costs money per request and adds latency to a page that has to render in under a second. Vector infrastructure is a system to run, monitor and pay for. Learned rankers need training data — interaction logs at volume — which an application with a thousand users does not have and will not have for a long time. And when the ranking goes wrong, you cannot say why. You can only retrain and hope.
A deterministic formula has none of those properties. It runs in microseconds with no network call, it costs nothing, it works from day one with no history, and when a post ranks where it ranks you can print the arithmetic that put it there.
That last point stopped being merely nice in Europe. The Digital Services Act requires platforms to explain the main parameters of their recommender systems. Two named parameters and a published formula satisfy that in a paragraph. A learned model does not.
There is also a failure mode specific to small products: an ML ranker trained on thin data mostly learns noise, and it does so invisibly. A formula with wrong constants is at least wrong in a way you can inspect and correct in an afternoon.
When you should reach for ML
I would rather make this argument honestly than win it cheaply.
Personalisation genuinely needs a model. If two users should see meaningfully different orderings of the same pool, no global formula gets you there.
Content understanding needs one. Semantic search, "more like this", detecting that two posts are about the same event — that is what embeddings are for and there is no arithmetic substitute.
Discovery at scale needs one. Surfacing something from a corpus of ten million items to the few hundred people who would care is retrieval, and it is a different problem from ordering a list.
And once you have millions of daily interactions, a learned ranker will beat a hand-tuned formula, because it can exploit patterns nobody would think to encode.
The point is not that ML is wrong. It is that these conditions are specific, and
most applications meet none of them. The honest default for an app with fifty
thousand posts is a formula, a WHERE clause and an index.
Even large platforms are structured this way underneath. Their feeds are multi-stage funnels: cheap indexable retrieval narrows millions of items to a few thousand, then progressively more expensive models score what survives. The sophisticated part operates on a few hundred items. The first stage — the one doing the heaviest lifting — is usually a filter and a sort.
Where this leaves the project
The library is being rewritten around that framing. It is not a database sort and it is not a recommendation system. It is the scoring stage — the part large platforms staff with an ML team — offered as a function you can read in full, with two presets that work out of the box and a documented protocol for tuning your own.
For an application with fifty thousand posts, that is the entire recommendation system. And I think a good number of teams currently reaching for embeddings would be better served by three lines of arithmetic they understand.
I would not have been able to write any of this at 19. But the formula I wrote then was closer to right than the reasoning I had for it, and that gap is the actual lesson: take the limits of your own function, test with empty data, and check that the parameter you documented is the one that governs the behaviour.
The project — RankingAlgorithm on GitHub. The v2 rewrite is in progress; issues and opinions welcome.