DEV Community

tauridev
tauridev

Posted on

What actually breaks when a free Cloudflare D1 database hits its 500 MB wall

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)
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)