We Burned Through Cloudflare's Free Tier in a Morning. Here's the Full Autopsy.
2026-10-14 · CallPages Team · Engineering, Postmortem

The timeline
-
~08:00 AST — D1 write quota (100,000 rows/day on the free tier) exhausted. Writes to the database stop.
-
08:00–08:45 — A monitoring probe triggers an unindexed aggregation against a large table: 6,439 executions of a
COUNT(*)/SUM(LENGTH(config_json))full-table scan, reading 2.23 million rows. Read-per-execution ratio: 347. This is the spike that kills us. -
~10:53 — Cumulative D1 rows read cross 5,000,000 — the free-tier daily read quota. Every subsequent query is refused. The
/p/*serve path can’t read page configs, so every tenant page 404s. Outage begins. -
~12:29 — Workers Paid ($5/mo) is activated. Quotas jump (25 billion rows read/month, 50 million rows written/month). The database breathes again.
-
~12:31 — Serving restored. Total outage: roughly 98 minutes.
October 14, 2026: how our own waste took every page down, and how it came back.
Confirmed via the Cloudflare dashboard, not via logs inference: reads 8,000,000/5,000,000 (160%), writes 109,000/100,000 (109%).
Diagnosis: two independent fires
Fire 1: the 2.23M-read full-table scan
The morning’s smoking gun came from the query that ate 26% of the entire day’s read budget in 45 minutes:
| Execution | Query | Execs | Rows read | Read ratio |
|---|---|---|---|---|
| 08:00–08:45 | COUNT(*) + SUM(LENGTH(config_json)) over the config table |
6,439 | 2,230,000 | 347 |
A read ratio of 347 means each execution scanned, on average, 347 rows to produce one aggregate number. There was no index the planner could use, so every probe was a full-table walk. This is the exact pattern behind several public 2026 D1 bill spikes ($800–$4,115) — we just got ours as an outage instead of an invoice.
One unindexed aggregation pattern was responsible for ~2.22M rows read per day.
Fire 2: the analytics firehose
Independent of the scan, our baseline write burn was absurd. Breakdown of every D1 write on a normal day:
-
59–74% — our own 30-minute uptime monitor, logging
page_viewevents for its health probes. -
17–22% — web crawlers and bots, faithfully recorded as “visitors.”
-
~2% — actual humans.
The write mix on a normal day: ~2% humans.
We were spending ~250,000 writes/day of uncapped demand to learn that Googlebot visited and that our own monitor was alive. That is not analytics. That is a denial-of-wallet attack against ourselves.
The fixes (shipped)
Three pull requests, all in the existing codebase, all costing $0 beyond the $5 plan we bought to stop the bleeding:
1. Stop writing non-human events (PR #295). page_view and product_page_view inserts from bots, crawlers, and our own monitor are dropped before they reach D1. Presence heartbeats write on state change only (roughly halving presence writes). Post-fix estimate: ~5,000–15,000 rows written/day, down from ~250,000/day of demand. That is a ~95% cut.
We deleted ~95% of our writes. They were never real traffic.
2. Cache the hot read path (PR #299). Cloudflare’s Cache API now fronts tenant page serving — 120s TTL on /p/*, 6h on /sitemap.xml, which had been rebuilding itself from ~1,774 rows on every single hit. ownedPageIds gets a 60-second memo; call history gets a proper index. Side effect: the ~2,000/day “Exceeded CPU Time” render errors die with the redundant renders.
3. Replace the scan with an indexed probe (PR #300). The aggregation becomes a guide_probe(user_id, epoch) table — one indexed row read per probe instead of a 347-ratio table walk. siteExistsForTenant becomes SELECT 1 … LIMIT 1 instead of COUNT(*). Savings: ~2.22 million rows read/day, and the entire spike class is structurally gone — no configuration can re-trigger it.
The economics
Here is the table that made the decision trivial:
| Resource | Measured usage | $5/mo Paid includes | Headroom | Overage price |
|---|---|---|---|---|
| D1 rows read | 8M/day ≈ 240M/mo | 25B/mo | ~100x | $0.001/M |
| D1 rows written | 109k/day ≈ 3.3M/mo | 50M/mo | ~15x | $1.00/M |
| Worker requests | ~80k/day ≈ 2.4M/mo | 10M/mo | 4x | $0.30/M |
Post-fix, usage drops further while the plan stays. Total cash outlay for this entire release: $5/month. Waste, for us, never actually cost dollars — it cost latency (uncached renders, table scans), reliability (this morning’s outage), and runway (every wasted row is headroom we can’t sell to future traffic).
The lessons
-
Meter the thing that can page you. We watched request counts and error rates; nobody watched the D1 quota counters. The counters that caught the outage existed the whole time. Set billing/quota alerts — it takes 15 minutes and prevents every class of surprise invoice or quota outage.
-
Your own tooling is your biggest tenant. When 59–74% of database writes come from your own monitor, the monitor is not observing the system — it is the system’s load. Probes should read, not write, production data. Write probe results to analytics infrastructure, never to the production database.
-
Bots are not visitors; stop counting them as such. Filtering non-human traffic before persistence is a data-quality fix with a 95% cost cut attached. The analytics get better at the same time they get cheaper.
-
A full-table scan is a latent outage, not a performance nit. At 5M rows/day of quota, a 2.1M-row spike in 45 minutes is a kill shot. Index discipline and cheap probe tables are availability work, not optimization work.
-
Spend where the ROI is infinite. The $5/mo Workers Paid plan turned two daily outage risks into 100x/15x headroom. Not every cost problem is solved by cost-cutting; this one was solved by a five-dollar receipt and then by deleting our own waste.
All numbers from the Cloudflare dashboard and D1 query stats, 2026-10-14. If we got a number wrong, we’ll correct it in public — that’s the deal.
Create your call page
Turn your website visitors into phone conversations — set up in under two minutes.
Create your call page →