Learn Center: D1 Efficiency Best Practices for CallPages
Why your page loads fast, your analytics count humans, and our database doesn't fall over β a field guide for developers building on (or with) CallPages.On 2026-10-14 we took a 98-minute full-platform outage because we burned through Cloudflare's D1 free-tier quotas with our own waste β reads at 160% of quota, writes at 109%. This article documents the engineering practices that came out of that day, written for developers who build on CallPages or run their own Workers + D1 stack.
1. Filter bot and self-inflicted traffic before it touches the database
Before the fix, our write mix was:
| Source | Share of D1 writes |
| Our own 30-minute uptime monitor | 59β74% |
| Crawlers and bots | 17β22% |
| Genuine humans | ~2% |
User-Agent against a maintained bot list (crawlers self-identify; the honest ones do).write-on-change), which roughly halved that write stream for us.The analytics got better at the same time they got cheaper: page owners' visitor counts now measure humans. Expect raw counts to drop when you do this β that's the correction working.
2. Cache the serve path with the Cache API β it's free and it's the edge
The most expensive query is the one you don't run. Tenant pages on CallPages change rarely, so the /p/* serve path is fronted by Cloudflare's Cache API (part of the Workers runtime, no separate billing):
/p/* page serve: repeat views are served from edge cache in single-digit milliseconds with zero D1 reads./sitemap.xml: this endpoint previously rebuilt itself from ~1,774 D1 rows per request. Caching it deleted an entire read stream in one line.Two smaller memoization wins in the same release:
ownedPageIds lookup (who owns which pages) is memoized for 60 seconds β it's called constantly and changes rarely.idx_calls_page_created, instead of scanning on timestamp filters.3. The guide_probe pattern: never COUNT(*) a table to check state
The morning's single biggest read burn was a COUNT(*) + SUM(LENGTH(config_json)) full-table scan used as a health/guide probe: 6,439 executions, 2.23M rows read, average read ratio of 347. The replacement pattern is simple:
-- one row per (user, epoch); written when state changes, not per request CREATE TABLE guide_probe ( user_id TEXT NOT NULL, epoch INTEGER NOT NULL, payload TEXT, PRIMARY KEY (user_id, epoch) ); A probe becomes one indexed point lookup instead of a table walk:
-- before: full-table scan, ratio ~347 SELECT COUNT(*), SUM(LENGTH(config_json)) FROM tenant_config WHERE user_id = ?; -- after: one indexed row read SELECT 1 FROM guide_probe WHERE user_id = ? AND epoch = ? LIMIT 1; Related hygiene: existence checks never need counts. We changed siteExistsForTenant from SELECT COUNT(*) β¦ to SELECT 1 β¦ LIMIT 1 β the planner stops at the first matching row instead of scanning for the rest of them.
Savings from this pattern alone: ~2.22M rows read/day, and the entire spike class is structurally un-retriggerable.
4. Index discipline: the checklist
Every release, verify:
EXPLAIN shows a full scan on a table that grows, that's a latent outage β at our old quota, 2.1M rows in 45 minutes was a kill shot.COUNT(*) on a user-facing table is a code smell; maintain a counter row or cache the aggregate with a TTL.LIMIT 1. COUNT(*) > 0 is never the right shape for "does this exist."5. The economics at a glance
| Resource | Daily usage (post-fix) | Plan includes ($5/mo Paid) | Headroom |
| D1 rows read | ~5.8M and falling | 25B/mo | ~100x |
| D1 rows written | ~5β15k | 50M/mo | ~15x |
| Worker requests | ~80k | 10M/mo | 4x |
Waste on this stack costs $0 in cash β it costs latency (uncached renders, table scans), reliability (quota exhaustion is an availability event), and runway (wasted rows are headroom you can't sell to future traffic). Spend where ROI is clear ($5/mo for 100x headroom: yes), delete everything else.
Thought leadership and ideas: Vasily Starostenko, founder of CallPages.