The table
| Players | rank: "live" | materialize daily | materialize hourly | Storage |
|---|---|---|---|---|
| 1,000 | $0 | $0 | $0 | 0.00 GB |
| 10,000 | $0 | $0 | $0 | 0.00 GB |
| 100,000 | $765 | $49 | $256 | 0.03 GB |
| 1,000,000 | $75,825 | $940 | $3,010 | 0.30 GB |
| 10,000,000 | $7,508,925 | $9,850 | $30,550 | 3.00 GB |
Per month, USD. The model ships as a committed script you can re-run.
Live rank is free to ten thousand players and ruinous past a hundred thousand
Under about ten thousand players the monthly read allowance absorbs everything, so rank: "live" is correct and costs nothing. That is most adopters. Do not engineer anything.
Live rank is quadratic. D1 bills rows scanned, not returned. A live rank counts every entry that beats you, so each check scans a slice of the board that grows with the board — while the number of checks grows with the player count too.
Ten times the players is about a hundred times the read bill. That is the whole shape of the first column.
Materialization is the fix, and it is pure D1
rank: { materialize: "0 * * * *" }Not a different database, a different engine, or a different product — a stored rank column and a cron. Nothing in the code you write changes, and the field is in the reference.
Your own score stays live either way, which hides most of the staleness from the player who cares most about it.
Refresh cadence is a continuous dial trading staleness for cost, and it stops paying for itself somewhere between hourly and every fifteen minutes.
Past a million players the dominant term flips
At ten million, materialized-daily is $9,850 a month — of which $8,950 is submission writes, not the refresh.
Beyond that point no cadence tuning helps. Only fewer windows or fewer submissions do.
The table is the worst case, and your board does better
Every figure assumes every submission writes. On the default best board it does not: a submission that fails to beat a player’s stored score is skipped by the upsert’s guard and writes zero rows.
Since submission writes are the dominant term — 85% of the bill past a million players — and most submissions do not improve a player’s best, a real board pays a fraction of the table.
At a million players, materialize-daily: roughly $220 at a typical improve rate, against $940 worst case.
The rank worker
A cron-triggered Workflow doing two jobs: the retention sweep, then the rank refresh if materialized.
You need it if rank is materialized or any board configures retention. A live board set that keeps everything — the default — needs no worker at all.
It does not have to be a separate Worker. It is a Workflow class, a scheduled handler and one cron trigger; pithy add leaderboard deploys them as a small dedicated Worker by default, but the same three pieces fold into your app Worker. A cron trigger is the only hard requirement.
At any realistic cadence it stays inside the free Workflow allowances — a run is a handful of steps, it persists no state, and steps awaiting D1 burn no CPU.
The refresh is chunked, and a lock keeps one running
D1 runs one query at a time and caps a query at 30 seconds, so an unbounded rewrite would hold the only thread against live submissions.
How many rows fit in one UPDATE is not the chunk size and never was a number to look up. A chunk is written in as many statements as the bound-parameter cap allows — chunkSize paces the walk and nothing else.
It checkpoints its keyset cursor per batch, so a board of any size ranks across as many durable steps as it needs and resumes from the last checkpoint on a crash. There is no per-run entry ceiling.
A D1 advisory lock keeps at most one refresh running. A second instance that cannot take the lock skips, so two passes never interleave their chunked writes into an incoherent rank set. A crashed instance’s lock ages out — one hour by default — so the next fire reclaims it.
Storage never binds
Three gigabytes at ten million players, against a ten-gigabyte cap.