
How Cloudflare D1 Scales to a Million Databases Without a Hot-Path Directory
Why One SQLite Database Per Tenant Breaks at a Million Tenants
A hundred tenants on SQLite-per-tenant is one of the best deals in software: real isolation, cheap backups, trivial local development, no noisy-neighbour problem at the query layer. At a million tenants, the same design collapses — the SELECTs aren't slower; everything around the query got expensive. Creating a database means picking a node. Deleting one means cleaning up routing metadata. Restoring one means moving bytes. Rolling a schema change means doing it a million times without creating a stampede.
This article works through how Cloudflare D1 scales to millions of SQLite databases without a hot-path directory: separating the control plane from the data plane, sharding routing metadata, and scheduling expensive lifecycle work behind a queue — then how to test the same pattern in your own multi-tenant service.
What Cloudflare Actually Published
Cloudflare's post "Under the hood, off the hot path: How D1 scales to millions of databases" — dated 13 October 2026 in the source feed this piece is built from — describes keeping expensive lifecycle work away from the request hot path while reads and writes stay fast, with database management spread across the platform.
One caveat before anything else: I'm working from the published title, summary, and snippet, not the full text. So this is a reading of the architecture shape the write-up describes — not a line-by-line retelling, and certainly not an independent benchmark of D1.
Scope: What This Article Claims and What It Does Not
My position: the reusable idea in D1's scaling story is the control-plane/data-plane split plus off-hot-path scheduling, and most multi-tenant serverless services could adopt both this quarter. What I am not claiming: that I reproduced Cloudflare's numbers, or that their routing metadata is built the way I'd build it. Anything below flagged as inference is mine, marked so you can discount it.
Separating the D1 Control Plane from the Data Plane
The Two Planes, Defined in D1 Terms
The data plane serves reads and writes against one tenant's SQLite database. The control plane decides whether that database exists, where it lives, what state it's in, and what happens when it moves. Per the write-up, lifecycle work stays off the hot path; that separation is the precondition for everything else working at scale.
If placement and query share a code path, every lifecycle operation inherits query latency and query availability. A create call waiting on the same machinery that serves your SELECTs turns a burst of tenant signups into a latency incident for customers who did nothing wrong.
Which Operations Belong on the Hot Path
| Operation | Plane | On the request hot path? | Cost if it lands there |
|---|---|---|---|
SELECT / INSERT | Data | Yes — this is the hot path | This is the work you are paying to protect |
| Database creation (stated) | Control | No, per the write-up | Signup latency welded to provisioning latency |
| Database deletion (stated) | Control | No | Contention with live traffic, plus cascade risk |
| Restore / point-in-time recovery (inference) | Control | No | Bulk I/O scheduled next to tenant reads |
| Schema migration (inference) | Control | No, but it touches every data plane | N× the work, unbounded fan-out |
| Placement / migration between nodes (stated) | Control | No | Cross-node copy plus an atomic routing flip |
Rows marked "stated" reflect what the Cloudflare summary describes — lifecycle work kept off the hot path. Rows marked "inference" are the generic shape I'd expect in any system that separates the planes; they are not claims about D1's implementation.
Why a Single Metadata Directory Becomes the Scaling Bottleneck
The Naive Design: One Table for Tenant Routing
One table, or one service, holding tenant → database routing. Fine at hundreds of tenants. At a million, every tenant action becomes contention against a single resource: one writer, one index, one availability domain.
Two Distinct Failure Shapes: Throughput Collapse and Blast Radius
Throughput collapse. The routing store becomes the serialization point. Reads may be cacheable; creates, deletes, and moves are writes, and writes queue.
Blast radius. If the directory is unavailable, every tenant is unreachable even though their databases are healthy and idle. The data plane is fine; the map is missing. This second failure is worse, because it converts a control-plane incident into a total outage.
Metadata Sharding: Splitting the Routing Layer
The fix is to partition the routing layer so lookups scale horizontally instead of queueing behind one writer. That is inference on my part about the mechanism, but it's also just sharding any hot index — the incentive is identical.
What the Public Details Do Not Confirm
I won't guess at the shard key, the replication model, the consistency guarantees clients see during a move, or whether routing metadata is cached at the edge. What would confirm them: a follow-up post with diagrams, a conference talk, or observable routing behaviour — say, measuring time-to-first-query after a create from several regions. If the metadata turns out to be strongly consistent and globally replicated, the hot-path argument weakens considerably; if it's eventually consistent, tenant-creation semantics matter far more than the summary implies.
Scheduling Expensive Database Lifecycle Work Off the Hot Path
Queue and Worker, Plainly: Pending, Ready, Failed
A request records intent and returns pending. A background worker performs the expensive action. The state flips to ready (or failed) later. Because the request never blocks on the expensive part, that part can be retried, rate-limited, and shed independently of user traffic.
Idempotency Keys and Leases Make Retries Safe
Two things make this pattern survive contact with production, and both are usually missing from a first implementation:
- An idempotency key derived from the tenant, not the request — so a retried create finds the existing job instead of provisioning a second database.
- A claim with a lease — a worker takes a job with an expiry and renews it while working, so a worker that dies mid-provision doesn't strand the job forever.
Without both, retries produce duplicate databases or half-created tenants. That's the most common way this pattern fails in practice.
A Generic Job-Claim Loop for Tenant Provisioning
The following is generic illustration, not Cloudflare's code, and not a claim about D1's scheduler.
// Generic illustration only — not Cloudflare code.
type Job = { id: string; tenantId: string; attempts: number };
const MAX_ATTEMPTS = 5;
async function claimOne(workerId: string): Promise<Job | null> {
// Atomic claim: only rows with no live lease. In Postgres this is
// `FOR UPDATE SKIP LOCKED`; in SQLite/D1 you rely on the single
// writer per database to serialize claims instead.
const rows = await sql`
update jobs
set leased_by = ${workerId},
lease_expires_at = now() + interval '30 seconds',
attempts = attempts + 1
where id = (
select id from jobs
where state = 'pending'
or (state = 'claimed' and lease_expires_at < now())
order by created_at
limit 1
)
returning id, tenant_id as "tenantId", attempts
`;
return rows[0] ?? null;
}
async function runWorker(workerId: string) {
for (;;) {
const job = await claimOne(workerId);
if (!job) { await sleep(1000); continue; }
try {
await provisionTenant(job.tenantId); // must be idempotent on tenantId
await markDone(job.id); // only after the side effect is durable
} catch (err) {
// attempts > MAX_ATTEMPTS -> dead-letter and alert.
// A permanently failing job is an orphaned tenant, which needs a human,
// not another retry.
await markFailed(job.id, job.attempts, err);
}
}
}
The Honest Trade-Off: Async Creation Breaks the 201
Pushing lifecycle work off the hot path buys stability and costs you synchronous creation. The client now needs a state machine — pending, ready, failed — instead of a 201 that means "done." That is real work: your API contract changes, your SDK changes, and so does your support runbook. Making that trade is usually correct. Discovering it from a support ticket is not.
Failure Modes: What This Costs and Where It Breaks
| Failure mode | Detection | Mitigation |
|---|---|---|
| Metadata shard hotspot | Per-shard write latency and queue depth diverge from the mean | High-cardinality shard key; cache directory reads; split directory reads from directory writes |
| Stuck background job | Age of the oldest pending job, not the count | Leases with expiry and heartbeats; dead-letter queue; alert on age |
| Orphaned tenant with no routing entry | Reconciler diffs database inventory against directory entries | Write routing intent before provisioning, so the orphan is a database rather than a customer |
| Migration falling behind tenant count | Schema-version lag and completion ratio over time | Batch, checkpoint per tenant, resume safely, cap concurrency |
| Control-plane outage that reads as a data-plane outage | Synthetic tenant-create plus first-query probe per region | Status page that names the plane; serve cached reads where safe |
The observability consequence is easy to miss: queue depth and job age become more important than p99 query latency, because the user-visible failure now happens in a queue the user cannot see. If your dashboards are organised around the query path, you're monitoring the part that still works.
Testing the Pattern in Your Own Multi-Tenant Service
Confirm the Lifecycle Shape with Wrangler
npx wrangler d1 create tenant-0001
The output shape, per the D1 documentation — I haven't run this against an account for this article, so treat it as documented shape rather than a captured session:
✅ Successfully created DB 'tenant-0001'
[[d1_databases]]
binding = "DB"
database_name = "tenant-0001"
database_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
npx wrangler d1 execute tenant-0001 --command="SELECT 1" --remote --json
[{ "results": [{ "1": 1 }], "success": true, "meta": { "duration": 1.2 } }]
The numbers aren't the point. What matters is that create and query are two different code paths with two different latency profiles — that's the property to reproduce in your own service.
Loop It and Watch Create and Query Latency Diverge
for i in $(seq -w 1 25); do
s=$(date +%s%N)
npx wrangler d1 create "tenant-$i" >/dev/null 2>&1
e=$(date +%s%N)
echo "tenant-$i $(( (e - s) / 1000000 ))ms" >> create-times.txt
done
Caveat: npx boot, auth, and network overhead dominate this loop, so read it as a smoke test. For a real curve, drive the D1 HTTP API from a Worker or from a script with a persistent HTTP client, measure create latency and query latency in the same harness at N = 10, 50, and 200, and plot both. If create latency grows superlinearly while query latency stays flat, your directory sits between the two code paths — and that's the bug this article is about.
Your Numbers Are Not Cloudflare's
Everything above is your system, your region, your account. A local reproduction cannot validate their sharding scheme, and it shouldn't be presented as if it does.
The Transferable Design Rules
- Rule 1 — anything that is not serving a query belongs behind a queue. Provisioning, deletion, restore, and migration are background work with an observable state machine, not synchronous API calls.
- Rule 2 — shard the routing layer before you shard the data. The directory is hit on every request that cannot be answered from cache; it becomes the bottleneck long before the data does.
- Rule 3 — make every lifecycle operation idempotent and observable. Idempotency key, lease, dead-letter, dashboard showing job age. No exceptions.
The interesting part of D1's architecture is not SQLite at the edge. It's that the platform treats database creation as a distributed-systems problem instead of a synchronous API call — and most multi-tenant services still don't. A POST /tenants that provisions synchronously is a promise you cannot keep at scale, and the bill arrives as an incident rather than a bug report.
Open Questions and What Would Change This Reading
Three things remain unconfirmed from the public material, each with a consequence:
- How routing metadata is partitioned. If the shard key is low-cardinality or region-bound, hotspots are still possible and sharding only moves the ceiling. What would confirm it: a follow-up post or observable per-region latency skew after tenant creation.
- The consistency model clients see during a migration. If clients see strongly consistent routing, the hot-path argument weakens — you're paying latency for correctness rather than for coordination avoidance. If it's eventually consistent, tenant-creation semantics (and how you surface
pending) matter more than the summary suggests. - Whether the described scheduler is D1-specific or shared Cloudflare platform infrastructure. If it's shared, the pattern is more transferable than a single-product write-up implies, and worth studying as a platform primitive rather than a database feature.
None of these change the two design rules with the highest payoff: separate the planes, and put lifecycle work on a queue.
Further Reading
- Source write-up: Cloudflare Blog, "Under the hood, off the hot path: How D1 scales to millions of databases" (13 October 2026). I'm linking the Cloudflare Blog index rather than a permalink I cannot verify.
- Docs: Cloudflare D1 documentation — the product reference for the CLI and API used above.
- Docs: D1 platform limits — useful when sizing a per-tenant database model.
- Spec: SQLite write-ahead logging — relevant to the single-writer behaviour that makes per-database claim loops cheap.


