engineering

D1 Efficiency Best Practices for CallPages

Field guide from our 2026-10-14 outage: filter bots before the DB, cache the serve path, never COUNT(*) to check state.

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:

SourceShare of D1 writes
Our own 30-minute uptime monitor59–74%
Crawlers and bots17–22%
Genuine humans~2%
Rule: classify every event at the edge, before the INSERT. Detection is cheap and non-controversial:
  • Check the User-Agent against a maintained bot list (crawlers self-identify; the honest ones do).
  • Tag first-party synthetic traffic explicitly β€” our monitor now sends a probe header, and probes take a read-only path. Probes observe the system; they must never be its load.
  • Apply the same discipline to client-side heartbeats: presence updates write only when state actually changes (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):

  • 120s TTL on the /p/* page serve: repeat views are served from edge cache in single-digit milliseconds with zero D1 reads.
  • 6h TTL on /sitemap.xml: this endpoint previously rebuilt itself from ~1,774 D1 rows per request. Caching it deleted an entire read stream in one line.
  • Purge-on-write: every page edit/publish invalidates its cache entries. Cache invalidation bugs are stale-content bugs, so treat the purge path as load-bearing code and test it like the write path.
  • Two smaller memoization wins in the same release:

  • The ownedPageIds lookup (who owns which pages) is memoized for 60 seconds β€” it's called constantly and changes rarely.
  • Call history reads got a covering index, idx_calls_page_created, instead of scanning on timestamp filters.
  • Side effect worth naming: caching the render also killed ~2,000/day "Exceeded CPU Time" errors. Uncached renders burn CPU as well as rows; the Cache API fixes both.

    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:

    Maintain a tiny probe table keyed by what you're checking:
    -- 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:

  • Every hot-path query has a covering index. If 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.
  • Aggregations over growing tables are precomputed or cached. COUNT(*) on a user-facing table is a code smell; maintain a counter row or cache the aggregate with a TTL.
  • Existence checks use LIMIT 1. COUNT(*) > 0 is never the right shape for "does this exist."
  • Watch the quota counters, not just errors. We monitored request counts and error rates; nobody watched D1 rows read/written. The counters that caught the outage existed the whole time. Quota/billing alerts take 15 minutes to set up and prevent this entire failure class.
  • 5. The economics at a glance

    ResourceDaily usage (post-fix)Plan includes ($5/mo Paid)Headroom
    D1 rows read~5.8M and falling25B/mo~100x
    D1 rows written~5–15k50M/mo~15x
    Worker requests~80k10M/mo4x

    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.

    Was this guide helpful?

    Share it with someone who could use it

    Share