3 min read
Separate your control plane from your data path before you need to
Cloudflare's D1 post shows why routing metadata and lifecycle operations must never touch the same path as live queries—and what happens when you get that separation right from the start.
By intermac
The most important architectural decision in any multi-tenant data product isn't the database engine or the caching strategy. It's whether your control plane can fail, slow down, or scale independently without affecting a single live query. Cloudflare's deep dive into how D1 scales to millions of SQLite databases is one of the clearest public accounts of what that separation actually looks like in production.
The core principle D1 is built around: no query request should ever touch a centralized, single-region component. Creating a database, deleting it, tracking storage usage—all of that can hit Postgres in a central data center. But executing a SQL query cannot. Those two concerns travel completely different paths.
That sounds obvious until you're building it. The pressure is always to reuse what's already there. You have a Postgres cluster with your account metadata; why not add a read there to resolve a database ID before forwarding a query? Because at a million active databases that read becomes the ceiling on your throughput. D1 sidesteps this by storing routing metadata in Workers KV, a globally distributed durable store. The key distinction the post draws is that Workers KV is not just a cache: if an item falls out of the edge cache, the read is served from KV's own storage, keeping Postgres entirely off the query path. A cache purge that wipes Hyperdrive's layer doesn't cascade into a Postgres thundering herd.
Idempotency is load-bearing, not decorative
The other pattern worth stealing is the idempotent lifecycle flow. When database creation fails mid-flight—say, the Durable Object initializes but the write to Postgres doesn't—the system retries with fresh IDs rather than reusing stale ones. A periodic heartbeat from each Durable Object then reconciles state against Postgres within 30 seconds, marking orphaned objects as deleted and ensuring Workers KV eventually reflects reality. No manual cleanup job, no support ticket.
That's a real discipline. It means every operation has to be designed as if the response will never reach the caller, because sometimes it won't. The checkpoint is the write to Postgres, not the HTTP 200.
Pre-aggregate before you need to query
The storage enforcement design is instructive for anyone building billing or usage limits. A SELECT SUM(storage) WHERE account_id = ? that scans every database row is fine for small accounts and catastrophic for platforms with millions of tenant databases. D1 solves this with incremental aggregation: a trigger writes deltas to a changes table, and a scheduled job running every second folds those deltas into a single per-account row. The query at enforcement time becomes a point lookup.
The trade-off is explicitness: you're now maintaining that aggregate as a first-class artifact, with the complexity that implies. But the alternative—scanning at read time—hides a scaling cliff that only appears when a large customer signs up.
The decision to make early
None of these patterns are easy to retrofit. Workers KV as the routing layer, idempotent creation flows, pre-aggregated totals—each one requires you to decide, before the system is under load, which operations can tolerate eventual consistency and which cannot. D1 drew that line explicitly: data plane operations must be fast and edge-local; control plane operations can be slower and centrally coordinated; background work can be asynchronous.
If you're building anything with per-tenant databases—or per-user state of any kind—draw that line now. The cost of crossing it later is higher than the cost of the discipline upfront.
Sources
- Under the hood, off the hot path: how D1 scales to millions of databases (original)
- Found via the TLDR newsletter