Database per Tenant on Cloudflare D1: How to Build It at Scale
Database per tenant on Cloudflare D1: the idempotent create flow, 30-second heartbeats and 1-second rollups that keep Postgres off the query path.

TL;DR Cloudflare’s D1 control-plane write-up shows how to build a database per tenant at scale: keep Postgres off the query path, make creation idempotent with one checkpoint write, and let a 30-second heartbeat repair whatever drifts. Live D1 databases grew 15x in a year and 65x in two, and these patterns are why that growth did not need a rewrite. You can reuse the patterns without Durable Objects.
Database per tenant is a design where every customer gets their own isolated database, so the hard problem moves from query performance to managing millions of small databases without a central bottleneck.
I read Cloudflare’s “How D1 scales to millions of databases” (published October 13, 2026) looking for the parts that transfer to a system you run. Cloudflare frames it as three kinds of work, and almost every decision follows from asking which kind a request is. This post pulls out the seven that matter and shows how I would apply each one. Nothing here is from running D1 at their scale, so where I add arithmetic or opinion, I say so.
How does a database per tenant design split work into three planes?
By what the request is allowed to depend on. D1 is a managed serverless database on SQLite, and each database is backed by one SQLite Durable Object. Requests fall into three categories, and each gets a different rule about central infrastructure.
| Plane | Work | May it use central Postgres? | Consistency |
|---|---|---|---|
| Data plane | Query requests | No, not on the hot path | Fast, at the edge |
| Control plane | Create, delete, list, import/export, time travel | Yes, via Hyperdrive | Few-second caches are fine |
| Background | Analytics, billing | Eventually, off any request | Eventually consistent |
The Edge API Worker receives dashboard, Wrangler and CLI calls. A Worker with a D1 binding skips that and calls the Eyeball Worker directly, which proxies queries to the right Durable Object and centralizes retries, errors and observability. Cloudflare’s seven guiding principles reduce to one habit: classify the request first, then decide what it may touch. That is the first pattern to steal.
How does D1 create a database without leaving orphans?
It makes one Postgres write the creation checkpoint and makes everything before and after it safe to repeat. The flow, as described in the post:
- Check the account’s limits (free tier or account limit). The non-deleted database count is read from Postgres read replicas through Hyperdrive, with a cache TTL of a few seconds.
- Generate a UUID and a Durable Object ID with
newUniqueId(). - Initialize the Durable Object with
init(options). Location hints and jurisdictions pass straight through. - Write the database’s metadata to Postgres. This is the checkpoint.
- Write the ID mapping to Workers KV. The final confirmation RPC runs in
waitUntil()and may fail without harm.
The trick is what a retry does. Each attempt mints a new UUID and a new Durable Object ID, so a failed attempt leaves at worst a stray object that nobody maps to, and two objects can never claim the same database. Once the Postgres write succeeds, the database counts as created even if the caller never sees the response.
Cloudflare deliberately avoids one big Postgres transaction here. Initializing a Durable Object may happen far from Postgres, and a transaction would hold a connection open the whole time. Multi-round-trip endpoints such as creation are also forwarded to data centers near Postgres, where round trips take a few milliseconds instead of hundreds. Their example, wrangler d1 create "demo-01", produced a database in WEUR in about 1.6 seconds end to end.
How I would apply it: pick the single write that makes a tenant real, put it after every step that can be retried, and never let a step before it be non-repeatable.
How do heartbeats repair a database control plane?
Each database checks in about every 30 seconds and corrects itself from the answer. Every Durable Object heartbeats the control plane on a 30-second interval plus per-database jitter. The response carries the database’s state (active or deleted) and the account’s limits.
That one loop closes three gaps:
- An orphaned object from a failed creation learns at its next heartbeat that its database is gone and marks itself deleted.
- A stale Workers KV entry gets repaired, since the first KV write was best effort. The object serializes its KV writes because KV is eventually consistent.
- Limit changes reach the object without anyone pushing them.
The object also stores its active or deleted state locally, so it rejects queries immediately even while KV is stale. Cloudflare notes heartbeats can read slightly stale data from Hyperdrive caches or replicas, and that the interval can be changed per database or account, or switched off.
The cost is easy to underestimate. This is my arithmetic, not Cloudflare’s: at a 30-second interval the heartbeat rate is simply active databases divided by 30.
Cloudflare targets hundreds of thousands of active databases, which puts the steady heartbeat load in the low thousands to tens of thousands of requests per second before a single user query. That explains why the interval is tunable, why jitter exists, and why heartbeats are allowed to read cache. A self-healing loop is cheap per tenant and not free in aggregate. Budget it.
How does a D1 query avoid central Postgres?
It looks up routing metadata in Workers KV, and treats Postgres as a rare fallback. The HTTP query path, per the write-up:
- Load the UUID-to-metadata mapping from Workers KV.
- On a rare miss, fall back to Hyperdrive reading Postgres replicas, checking a caller-location cache, then a Postgres-location cache, then the replicas.
- Call the Eyeball Worker through a Service Binding.
- The Eyeball Worker forwards the query to the Durable Object.
- Results return to the user.
Two details are worth copying. First, newUniqueId() avoids a name-to-ID lookup. Because the Eyeball Worker also needs namespace metadata, a name lookup would replace one lookup with two. Cloudflare still recommends addressing Durable Objects by name as a starting point and using unique IDs only when needed. Second, Workers KV is treated as durable, not as a cache. A miss at the edge is served from KV storage, not Postgres. If a cache purge sent millions of databases at Postgres at once, you would get a metastable failure, so the design makes that impossible by construction.
How I would apply it: if your hot path reads a cache that can be purged, ask what the origin does when the whole cache is empty. If the answer is “falls over”, the cache is load-bearing and needs to be a durable store.
How to enforce storage limits without row-lock contention
Precompute the per-account total, and move the writes through a change log instead of updating one hot row. Limits are 5 GB per account on Free and 1 TB per account on Workers Paid, raisable on request. Each Durable Object publishes storage updates to a Kafka-like queue, aggregated per edge server first, and consumers are capped to limit write load on Postgres.
| Table | Holds | Why it exists |
|---|---|---|
database_stats | Latest size per database | Source rows |
account_database_stats | Precomputed per-account total | Cheap limit check |
account_database_stats_changes | Change log of deltas | Avoids row lock contention |
A trigger writes deltas into the change log. A scheduled job runs every second and applies the aggregated deltas with a DELETE ... RETURNING CTE. Totals are eventually consistent, and an over-limit account has every size-increasing write forbidden. The limit check itself is a single read, SELECT storage FROM account_database_stats WHERE account_id = ?, through Hyperdrive with a few-second cache.
Here is a minimal sketch of the rollup shape. It is my illustration of the pattern, not Cloudflare’s code, so adapt the names and the locking to your database:
-- Drain the change log and fold it into the total in one statement.
WITH drained AS (
DELETE FROM account_database_stats_changes
RETURNING account_id, delta
), summed AS (
SELECT account_id, sum(delta) AS delta
FROM drained
GROUP BY account_id
)
UPDATE account_database_stats s
SET storage = s.storage + summed.delta
FROM summed
WHERE s.account_id = summed.account_id;Writers only ever insert into the log, so they never contend on the per-account row, and one job per second does the contended update. Cloudflare also notes the cache key includes only the account ID, so all of an account’s databases share one cache entry. They rejected account-plus-database keys because those would lower the hit rate. ClickHouse events remain the billing source of truth, with a periodic storage snapshot for history.
How do free-tier row limits skip Postgres entirely?
By pushing the verdict to the edge through a globally replicated key-value store. Every query’s rows read and written, including index updates, are tracked, and a usage event goes to a ClickHouse cluster when the query completes. A limiter in Core periodically asks ClickHouse for the day’s totals per account.
For an over-limit free account it sends a deduplicated email and sets a Quicksilver key that expires at the end of the day. Quicksilver is Cloudflare’s internal globally distributed key-value store with sub-second replication, and it is what Workers KV Instant is built on. Durable Objects check it at the edge and reject queries while it is set. Upgrading to Workers Paid sends a queue message that clears the key.
Postgres sees none of that traffic, and Cloudflare says it may move other limits to the same pattern. The general lesson: a limit is a small piece of state with a TTL, so store it where the enforcement happens instead of computing it on every request.
When should you copy this, and what breaks if you copy it literally?
Copy the principles when tenant count and tenant activity are both large. Skip the machinery when they are not. Here is how each pattern maps to a smaller system:
| Pattern | Scale that justifies it | Cheaper starting point |
|---|---|---|
| Three-plane classification | Any multi-tenant system | A written list of what each endpoint may call |
| Checkpoint creation | Any multi-step provisioning | One idempotency key and one commit row |
| Heartbeat repair | Thousands of long-lived tenant objects | A nightly reconciliation job |
| Durable routing cache | A purgeable cache in front of a central DB | A read replica with warm-up |
| Change-log rollup | Hot per-account counters | A periodic GROUP BY into a summary table |
| Edge-stored limit flags | Per-request limit checks at volume | A feature flag with a TTL |
Three ways this goes wrong if you take it too literally. A 30-second heartbeat at your scale may cost more than the drift it repairs, so measure before you adopt it. Eventually consistent totals mean an account can briefly exceed its limit, so only use them where a small overshoot is acceptable. And the write-up is Cloudflare describing its own system, so the 15x and 65x growth figures and the load tests are claims I could not reproduce. If you have not stress-tested your own failure paths, the method in how to scope AI agent access on Cloudflare Workers and the baseline logic in telling a traffic anomaly from an outage are where I would start.
If you are earlier in the journey, deploying an MCP server on Cloudflare Workers and running CI/CD on Cloudflare Workflows show the same platform from the application side. For the relational counterpart of this trade-off, see Postgres migration safety and table locks, zero-downtime migration with dual writes, and use Postgres for everything.
Sources
- Under the hood, off the hot path: How D1 scales to millions of databases, Cloudflare Blog, October 13, 2026. All architecture details, growth figures and the 1.6 second example come from this post.
- Cloudflare D1 documentation, for current limits and pricing.
- Cloudflare Durable Objects documentation, for
newUniqueId()and addressing objects by name.
FAQ
Is D1 really one database per Durable Object? Yes. Cloudflare says each D1 database is backed by one SQLite Durable Object, which is what lets it scale to millions of created databases with hundreds of thousands active at once. Queries go to that object, while a separate control plane tracks metadata, limits and billing.
Why does D1 not create a database in one Postgres transaction? A single transaction would hold a Postgres connection open while the Durable Object initializes, and that object can be far from the database. D1 instead treats the Postgres write as a creation checkpoint, retries with fresh IDs before it, and lets heartbeats repair anything after it.
What does the 30-second heartbeat actually do? Each Durable Object reports to the control plane about every 30 seconds, plus per-database jitter, and gets back its state and account limits. That is how an orphaned object learns its database was deleted and how a stale Workers KV entry gets repaired. The interval can be changed per database or account, or disabled.
Can I copy this design for my own SaaS? Copy the principles, not the stack: idempotent creation with a single checkpoint, a self-healing heartbeat, a durable routing cache, and precomputed aggregates. Most teams do not need Durable Objects or a Kafka-like queue, and the design only pays off when tenant count and activity are both large.
How does D1 stop a free account from running up row usage? A limiter queries ClickHouse for the day’s per-account totals, and for an over-limit free account it sets a Quicksilver key that expires at the end of the day. Each Durable Object checks that key at the edge and rejects queries while it is set. Upgrading to Workers Paid clears the key through a queue message.
Frequently asked questions
Google Search · Preferred sources
Prefer this site on Google
If you already read this writing, add umesh-malik.com as a Preferred Source. Google can then highlight it with a preferred badge in Top Stories, AI Overviews, and AI Mode — for you, not as a site-wide ranking boost.
Related Articles

Web Engineering
Self-Host Cloudflare Workers: What workerd Runs and celld Adds
Self-host Cloudflare Workers with workerd today, and add celld when Durable Objects must scale. The working config, the bucket setup, and what stays alpha.

Web Engineering
Build a Cloudflare Streamline Video Pipeline: 4 Steps, 1 Session
Build a Cloudflare Streamline video pipeline: a Worker and Durable Object steer FFmpeg in a Container. See the fixed 4-step order and the limits to know.

Web Engineering
Fix the Datasette SQL Injection: Why execute-sql Won't Save You
The Datasette SQL injection patched in 1.0a38 and 0.65.3 leaks private tables via unescaped filter columns. The check, upgrade, and why execute-sql is no fix.
Keep reading
Get new posts on AI, Claude Code & LLMs
New deep-dives on AI engineering, Claude Code, and developer tooling — follow along however you prefer.
About the Author
Software engineer writing about AI, Claude Code, LLMs, OpenAI, Anthropic, and developer tooling. 5+ years building production systems at Expedia Group, Tekion, and BYJU'S.