We Burned Through Cloudflare's Free Tier in a Morning. Here's the Full Autopsy.

2026-10-14 · CallPages Team · Engineering, Postmortem

We Burned Through Cloudflare's Free Tier in a Morning. Here's the Full Autopsy.

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_view events 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 →

Keep exploring

← All posts