Take the log, delete the cron job: decay ranking in one indexed column
A logarithm replaces both the payment table and the timer that would rewrite every row of it. The concurrency bug it does not remove is the more useful half of the writeup.
The Engineer · Build desk
What happened
- A board of 1,000 tiles lets anyone pay to take a tile from its holder, ranked by a heat value that decays continuously.
- The full payment history of a tile is collapsed into a single amplitude and timestamp pair, updated only when a payment lands.
- Taking logs leaves a per-row constant with no time variable in it, stored as an indexed rank_key and sorted directly by SQL.
- Time is counted in days from an epoch near launch, because unix seconds put roughly 4800 into the key and burn four digits of a double.
- Tile claims are staked in a synchronous block against an in-memory Set, not via a database lock check.
Why it matters
- cost The write budget stops tracking clock ticks and starts tracking revenue events, which is the difference between billions of no-op row writes a day and one write per payment.
- constraint Per-tile half-lives are foreclosed by the same algebra that removes the job, and the failure would be a silently wrong sort order rather than an error.
- capability Ordering correctness no longer depends on a worker's health, so there is no stale-ranking incident to detect or backfill after an outage.
- exposure Any tile whose lock check crosses an await inside the Durable Object stays reachable by a second concurrent claimant, and payment intents are on the far side of that await.
Ranking needs order, not values. The decay here is multiplicative and shared: in log space, advancing the clock subtracts the same number from every row at once [5][6]. A uniform translation of a column cannot change which entry is on top. What a timer would be doing, a million times a tick, is moving every row by an identical amount and then discovering nothing changed places [3].
That symmetry is the load-bearing assumption, not a convenience. The term you throw away is -k*t, and it is common to all rows only because k is common to all rows [6]. Give tiles individual half-lives and it becomes -k_i*t, which varies per row, and an index on ln(P) + k_i*t_P quietly returns the wrong order with no query error to tell you [18].
The constants check out. At a three-day half-life, k = ln2/3 = 0.2310 per day [2][14], and the seconds-domain figure quoted in the post, 2.674e-6, times 86,400 is that same 0.231 [9][15]. The twenty-year number needs a footnote: at 0.231 per day the key gains about 84 a year, so 1687 is twenty years measured from the epoch, not twenty years past a present value of 55, which itself puts the epoch roughly 238 days back [10][19]. Either reading is far outside a double's exponent range anyway, which is the author's point about never calling exp on rank_key directly [10].
Display still needs a number, and that is where the discipline shows up. You exponentiate only after shifting the domain to now, and the author reports the shifted value sitting in the 6 to 10 range in log terms [10], which is a heat of about 400 to 22,000 [17]. Additions stay in log space too, using a log1p-based log-add so a small payment landing on top of a large one does not round away [8].
The second half of the writeup is less tidy and worth more. Having removed the scheduled job, the author still had to build a lock by hand [13]. All 1,000 tiles live in one Cloudflare Durable Object, which gives a single JS execution stack with one thing running at a time [11], and that reads like free mutual exclusion. It is not. Every await is a yield point, and the input gate only holds delivery across storage operations, not across a D1 query or an outbound fetch [12]. A SELECT that checks for existing locks lets two callers both see zero locks and both proceed [12]. The fix is to do the check and stake the claim in a block containing no await at all, against a plain in-memory Set, where the single thread genuinely guarantees no interruption [13].
Both halves come from the same accounting. State that changes on its own has to be recomputed, and state that changes only when money moves does not. The algebra pushes the decay into a constant so nothing has to run; the in-memory Set pulls the claim out of the database so nothing has to be trusted across a yield. Neither one was bought with more infrastructure.
What to watch
- Whether the in-memory Set survives Durable Object eviction or restart, and what happens to inflight claims when it does not.
- Whether the design ever needs per-tile half-lives, which would break the shared-k assumption the single indexed column rests on.
- How a float rank_key index behaves under sustained write contention once row counts approach the million the author cites.
Clarity's read
What the record supports and how the coverage leans. The claims behind it follow.
Reality
- Evidence66
- Adoption12
- Hype gap+12
- Incentives55
- Confidence58
Claim ledger
Ranked by verification strength, evidence, and original report placement.
- [1]
The author built a board of 1,000 tiles where anyone can pay to take a tile away from its current holder; tiles are sorted by a decaying 'heat' value, so the board reorders itself continuously.
- [2]
Heat is the sum of every payment a tile has received, each decaying exponentially: heat(t) = sum of A_i * 2^(-(t - t_i)/H), with half-life H of 3 days in the author's case.
- [3]
The obvious implementation is a scheduled job recomputing every row on a timer; the author notes that with a million rows the timer stops being a detail and becomes the whole system.
- [4]
Individual payments never need to be stored: the entire sum is representable by a single pair (P, t_P), updated on a new payment A as P' = P * 2^(-(t_new - t_P)/H) and t_P' = t_new, giving one row and two columns regardless of how many payments a tile has taken.
- [5]
With k = ln2/H, ln(heat(t)) = ln(P) - k*(t - t_P) = [ln(P) + k*t_P] - k*t.
- [6]
The -k*t term depends only on current time and is therefore identical for every row in the table, so it cannot affect relative order and can be dropped.
- [7]
What remains, ln(P) + k*t_P, contains no t; stored as an indexed rank_key column, SELECT * FROM tiles ORDER BY rank_key DESC is a permanently correct real-time ranking, with a row rewritten only when money actually moves and no scheduled job ever running.
- [8]
Heat is added in log space using a numerically stable log-add (hi + Math.log1p(Math.exp(lo - hi))) so that a small payment on top of a large one does not vanish into floating point.
- [9]
Time units matter because rank_key contains k*t_P: in unix seconds k = 2.674e-6 and k*t is roughly 4800 today, burning four significant digits of a double, while in days k = 0.231 and k*t is about 55, leaving around 12 decimal digits of headroom, or sub-microsecond resolution, with an epoch chosen near the launch date.
- [10]
rank_key must never be exponentiated directly because it grows linearly with time: the author's is about 55 today and will be about 1687 in twenty years, and Math.exp(1687) is Infinity; for display you exponentiate in the domain shifted to now, exp(rankKey - K*toDays(now)), which equals ln(heat(now)) and stays in the 6 to 10 range.
- [11]
All 1,000 tiles live in a single Cloudflare Durable Object, which gives one JS execution stack where only one thing runs at a time; the author says this sounds like it makes critical sections free but does not.
- [12]
Every await is a yield point, and the Durable Object input gate only blocks delivery during storage operations, doing nothing across a D1 query or an outbound fetch; a SELECT-based lock check is therefore broken because two requests can both see zero locks and both proceed.
- [13]
The check and the claim must happen in a purely synchronous block containing no await, staking the claim in a plain in-memory Set, because JavaScript's single thread guarantees that block cannot be interrupted.
- [14]
For a three-day half-life the decay constant in per-day units is ln(2)/3 = 0.2310, matching the 0.231 figure quoted for the days-based unit.
- [15]
The seconds-domain constant 2.674e-6 per second converts to 0.2310 per day, consistent with the days-domain figure.
- [16]
A recompute job over a million rows firing once a minute rewrites about 1.44 billion rows per day to produce an ordering identical to the one it started from.
- [17]
A shifted log-heat range of 6 to 10 corresponds to displayed heat values of roughly 403 to 22,026.
- [18]
The dropped term is only row-invariant while k is shared, so per-row half-lives would make the term -k_i*t row-dependent and an index on ln(P) + k_i*t_P would no longer yield correct ordering.
- [19]
At 0.231 per day, rank_key gains about 84.4 per year, so 1687 corresponds to about twenty years from the chosen epoch rather than twenty years beyond a current value of 55; a current value of 55 implies the epoch is about 238 days in the past.
Get this ledger inside ChatGPT. Ask about any of our stories and the answer brings its figure, each claim’s verdict and the sources. Free, no account.
Sources
1 independent publisher whose own reporting we read for this story.
- dev.toRanking by exponential decay without a cron job
1 article · August 24, 2026
Topics and entities
Follow any of these and your For You feed starts watching them — no settings page required.
Topics
Entities
- Cloudflare D1Follow
- Cloudflare Durable ObjectsFollow
- dev.toFollow
- JavaScriptFollow
- Million Dollar HomepageFollow
- tilesquat.comFollow