Every "free serverless DB" comparison ranks capacity, price, and cold-start. Almost none answer the question you actually hit in production: what happens at the free-tier wall? So I filled a Cloudflare D1 database to its limit over the REST API and poked it.
The honest short version: the wall is not a full read-only lockout, a plain DELETE clears it immediately (but that recovery is not quota-free), and — the part I got wrong twice — building an index on a blob column roughly doubles your storage, so it slams into the wall on its own. This post has been revised twice: my own size_after logs, and then a follow-up experiment, corrected me. I'll show both corrections instead of hiding them.
Scope: Workers Free, throwaway DBs, over the D1 REST API /query, 2026-07. The coarse wall was reproduced on a second, freshly-created database; the exact-ceiling pin and the index-doubling are each n=1 (flagged). This is a REST probe, not a Worker Binding probe — don't generalize endpoint-specific behavior (especially VACUUM).
"The free-tier wall" is not one wall
D1 has several walls, with different scopes and — per Cloudflare's own error list — different messages:
| Wall | Scope | Value (Free) | Documented message |
|---|---|---|---|
| per-DB storage | per database | 500 MB (Paid: 10 GB) |
Exceeded maximum DB size — action: "delete rows / shard" |
| account storage | account | 5 GB | Your account has exceeded D1's maximum account storage limit… |
| daily rows read | account | 5,000,000/day | daily-limit-exceeded → "can't run queries" |
| daily rows written | account | 100,000/day | same (INSERT, UPDATE, and DELETE all count as writes) |
(Free daily counters reset at 00:00 UTC; storage is not daily-reset. Max row 2 MB; 100 columns; 10 DBs/account. All confirmed against the D1 docs on 2026-07-09.) I measured the first one.
Result 1 — the wall is 500 MB (decimal); pinned to one row on one DB, reproduced coarsely on a second
Filling with bulk inserts stalls around ~499.9 MB. To pin it, I then looped single-row inserts at the wall:
… 24 single-row inserts succeed (size_after climbs)
25th: HTTP 400 [{"code":7500,"message":"Exceeded maximum DB size"}]
last successful size_after = 499,994,624 bytes (≈ 499.99 MB ≈ 476.83 MiB)
That ceiling — 499,994,624 — is 500,000,000 (decimal 500 MB) minus 5,376 bytes, enforced at SQLite's 4 KB page granularity (499,994,624 = 122,069 × 4096). I reproduced the coarse wall on a second, freshly-created DB: it stopped at the identical 499,884,032 and returned the same 7500. One caveat so you can hold me to my own logs: the redacted results/*.jsonl carry no account IDs, and served_by_colo is Anycast edge routing, not an account signal — so what's verifiable from the evidence is a second database, and the byte-exact 499,994,624 pin is from the single-row probe on one of them (n=1). The wall firing at all means these DBs are on Workers Free (Paid = 10 GB).
Result 2 — not read-only, but small writes aren't "exempt" either (measured)
My first draft claimed small writes slip past the wall while big ones don't. Wrong — and my own numbers show why. Those 24 single-row inserts above consumed only 499,994,624 − 499,884,032 = 110,592 bytes (~110 KB) of headroom before even a single-row insert was rejected. So:
- Reads always pass — near-tautological for a storage cap; reads don't grow the DB.
- Writes pass only while headroom remains. Small writes weren't bypassing the wall; they were filling the last sliver before it. Call it slack, not exemption. (One headroom-exhaustion sequence, but the 25th insert failing at zero headroom is a clean counterexample to "small writes are exempt" — one is enough.)
Result 3 — recovery is a plain DELETE, no upgrade (but it spends write quota)
DELETE WHERE k < 20000 → 19,998 rows; size_after 499.9 MB → 243,884,032 (~244 MB) immediately; rows_written = 19,998
bulk INSERT (after delete) → restored
VACUUM → 7500 "cannot VACUUM from within a transaction: SQLITE_ERROR" (maybe REST-specific)
DELETE drops D1's reported size immediately and writes resume — no VACUUM, no plan upgrade (recovery class R1). Two honest qualifiers: (1) it isn't quota-free — D1 counts DELETE as a write (docs: "Write operations include INSERT, UPDATE, and DELETE"), so this one 19,998-row delete spent ~20% of the daily 100k budget, and the cost scales with row count, not bytes freed (this delete freed ~256 MB ≈ 12.8 KB/row; whether it's linear per chunk I didn't separately measure); (2) size_after is D1's reported size — the meter dropping doesn't prove the physical file shrank (cf. workerd#1618, local).
Result 4 — CREATE INDEX doubles your storage (the thing I got wrong twice)
At the wall, CREATE INDEX fails with out of memory: SQLITE_NOMEM. My first draft lumped that in as "large allocation blocked"; after review I "corrected" it to "that's a memory limit, independent of the storage wall." Both were wrong.
On a control DB at 300 MB — well below the 500 MB wall — CREATE INDEX failed not with SQLITE_NOMEM but with 7500 "Exceeded maximum DB size" (storage!). Below the wall, a storage error? I measured the mechanism directly — deleted the control to 80 MB and indexed:
CREATE INDEX ON bench(v) at 84,029,440 (80.1 MiB) → OK
size_after 84,029,440 → 167,923,712 = ×1.998 growth (measured once, at ~80 MB)
An index on a blob column carries a copy of the column's data as its keys, so it roughly doubles storage — inferred from this one growth point, not proven in general. If that ×2 holds at other sizes:
| storage before index | after (~×2) | result |
|---|---|---|
| 80 MB | 160 MB | ✅ succeeds (under the wall) |
| 300 MB | ~600 MB | ⛔ Exceeded maximum DB size (over the 500 MB wall) |
| 500 MB (at the wall) | — | ⛔ SQLITE_NOMEM (OOMs during the build) |
So the index failure is storage-bound via the index's own footprint, not "memory, unrelated to the wall." A rough rule of thumb — if the ×2 holds — is that on D1 Free you can only index a column holding up to ~250 MB of data (2 × 250 = 500 MB), but that's an extrapolation, not measured near 250 MB. The SQLITE_NOMEM is just what you see when you're already at the wall.
The error code, and what I did not measure
Cloudflare's error list classifies these by message (Exceeded maximum DB size, …account storage limit…, …isolate memory limit…); the number 7500 appears nowhere in the docs. So 7500 is a generic REST wrapper — the message is the discriminator.
Not measured / n=1 / extrapolated (so not claimed): the index ×2 is a single measurement at one size/row-shape; the ~250 MB threshold is extrapolated (not measured near 250 MB); DELETE linearity across chunks is not measured (one 19,998-row delete); the account 5 GB wall, the daily read wall (5M/day — reads stop, account-scoped), and Worker Binding behavior are all untested; size_after behaves like a coherent logical meter (dropped on the one DELETE, ~doubled on the one index build) but the docs don't say physical vs logical. One full cycle (fill 39k + recover 20k + control 24k ≈ 84k rows_written) fits in one UTC day but not two.
Reproduce it
git clone https://github.com/axiom-pro/free-tier-quota-probe
cd free-tier-quota-probe
npm install && npm run selftest
# .env: CF_ACCOUNT_ID / CF_API_TOKEN (D1:Edit) / D1_DATABASE_ID / D1_CONTROL_DATABASE_ID / BILLING_SAFE=confirmed
node --env-file=.env bin/d1-admin.mjs create wallprobe # create the DB via API (no dashboard needed)
npm run ping -- --target d1
npm run fill -- --target d1 --confirm-destructive
npm run probe-slack -- --target d1 # pins the wall + measures the headroom
npm run fill -- --target d1 --control --cap-mb 300 --confirm-destructive
npm run probe-index -- --target d1 # the index-doubling test
Node ≥ 20, ESM, D1 over native fetch (60 s timeout + one retry). Redacted captures are frozen in results/*.jsonl; predictions (and the follow-up amendment) are in PREREGISTRATION.md. Repo: https://github.com/axiom-pro/free-tier-quota-probe
I write about measurement discipline — filling the wall instead of quoting the limit, and correcting my own assumptions on the record. This post's Results 2 and 4 are the live examples: I reasoned wrong twice and only the measurements got it right. Written with AI assistance; every number is from the captured `results/.jsonl`, and the doc facts were re-verified against Cloudflare's D1 docs on 2026-07-09. A Japanese version of this write-up also exists.*
Top comments (0)