How Cloudflare D1 Scales to a Million Databases Without a Hot-Path Directory

How Cloudflare D1 Scales to a Million Databases Without a Hot-Path Directory

pr0h0•
cloudflared1sqliteserverlessdatabases
AI Usage (83%)

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

OperationPlaneOn the request hot path?Cost if it lands there
SELECT / INSERTDataYes — this is the hot pathThis is the work you are paying to protect
Database creation (stated)ControlNo, per the write-upSignup latency welded to provisioning latency
Database deletion (stated)ControlNoContention with live traffic, plus cascade risk
Restore / point-in-time recovery (inference)ControlNoBulk I/O scheduled next to tenant reads
Schema migration (inference)ControlNo, but it touches every data planeN× the work, unbounded fan-out
Placement / migration between nodes (stated)ControlNoCross-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:

  1. An idempotency key derived from the tenant, not the request — so a retried create finds the existing job instead of provisioning a second database.
  2. 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 modeDetectionMitigation
Metadata shard hotspotPer-shard write latency and queue depth diverge from the meanHigh-cardinality shard key; cache directory reads; split directory reads from directory writes
Stuck background jobAge of the oldest pending job, not the countLeases with expiry and heartbeats; dead-letter queue; alert on age
Orphaned tenant with no routing entryReconciler diffs database inventory against directory entriesWrite routing intent before provisioning, so the orphan is a database rather than a customer
Migration falling behind tenant countSchema-version lag and completion ratio over timeBatch, checkpoint per tenant, resume safely, cap concurrency
Control-plane outage that reads as a data-plane outageSynthetic tenant-create plus first-query probe per regionStatus 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.

Share this post

More posts

Comments