Cloudflare’s D1 Control Plane Turns Millions of Databases Into a Lag Budget the Post Never Adds Up
Cloudflare’s 24-minute D1 post keeps queries off Postgres and gives every limit its own repair loop. Cloudflare’s docs and a local Postgres 18.6 test show what holds up and which lags the post never...
In a 24-minute post on Cloudflare’s blog on October 13, David Laban and Lambros Petrou describe how D1, the company’s managed SQLite database, scales a control plane for platforms that create a database per user, app, preview environment or tenant. As of October 8 it reports “a 15x growth in the number of live non-deleted databases” over the past year and 65x over two years, plus 18x growth in daily active databases. The scale itself is given only roughly, as “millions of created databases, hundreds of thousands of which are active at any point.”
Table Of Content
- Three planes, and queries stay off Postgres
- Creating a database: one checkpoint, new IDs on retry, and a 30-second cleanup
- Routing: Workers KV as the lookup, a Durable Object as its only writer
- Limits: where lag is the product
- Storage: a queue, a trigger, a delta log and a one-second job
- What a local Postgres showed
- Test 1: a delta that lands between statements
- Test 2: three aggregators that do not coordinate
- Test 3: one hot row against a delta log
- Rows: a ClickHouse query, a limiter and a Quicksilver key
- Adding up what is stated
- What a platform meets before the architecture
- What to copy and what to measure first
- What I tested and what I did not
The design choice is simple to state. Queries never touch the central Postgres. Everything else, from creating and deleting databases to enforcing limits and billing, gets its own consistency level and its own repair loop. This piece reads the post as a set of lag budgets: for each piece of state, who is the source of truth, how stale may a reader be, and what repairs the difference. The post answers the first and third questions well. It answers the second only in part, and the missing parts are the ones a team copying the design would meet first.
To test that, I read the post against Cloudflare’s own docs for D1, Workers KV, Hyperdrive, Durable Objects and the Cloudflare API, and I rebuilt the post’s usage-aggregation query on a local PostgreSQL 18.6. What I found:
- The printed aggregation statement stayed exact in a stress run with three uncoordinated aggregators (zero drift after 213,315 updates). A three-statement version of the same job drifted in both directions at Postgres’s default isolation level.
- Updating one per-account total row in every transaction topped out near 7,000 to 11,000 transactions a second on my machine. Appending delta rows reached about 59,000 a second with 48 writers, 8.2 times as much.
- The lags the post states add up to a little over half a minute. The queue, replica and jitter bounds are not stated.
- Cloudflare’s docs show the defaults a platform meets before any of this: 50,000 databases per account, 1,200 API requests per five minutes per user, and a 1 TB storage pool.
Three planes, and queries stay off Postgres
The post sorts every operation into one of three planes and gives each a different allowed dependency, in its own words:
| Plane | The post’s definition | What it may depend on |
|---|---|---|
| Data plane | “any database query related request; should be as fast as possible and always at the edge” | Nothing central: edge Workers, Workers KV and one Durable Object per database |
| Control plane | “any HTTP API operation or internal flows that run in response to a non-query user request; they can depend on central single-region components” | Postgres in the central data centers the post calls “Core,” reached through Hyperdrive |
| Background asynchronous plane | “any analytical and billing-related workflow; can be eventually consistent and runs anywhere” | A Kafka-like queue, ClickHouse, Quicksilver, and batched writes into Postgres |
Each D1 database is backed by one SQLite Durable Object, which the post says makes it “very easy to create millions of them.” Queries arrive two ways. The HTTP API passes through an Edge API Worker that handles authentication, authorization, rate limiting and entitlement checks, then calls an Eyeball Worker. A Worker with a D1 binding goes straight to the Eyeball Worker, because the binding “carries encoded information that authorizes access to one specific D1 database.” The create, delete, list, import, export and time travel endpoints are what the post calls the CRUD endpoints. They use Hyperdrive to reach Postgres, through one configuration that caches reads from replicas and one that does not cache and targets the primary.
The rule that organizes the rest is the post’s own: “Avoid centralized single-region components in any hot path for data plane operations (in our case all query requests).” Cloudflare’s D1 release notes show the split arriving in stages. On May 30, 2025, query endpoints stopped proxying authentication to Cloudflare’s core data centers, while “D1 control plane endpoints such as those to create and delete databases see smaller improvements, since they still require access to Cloudflare’s core data centers for other control plane metadata.”
The demand driver is the one the post opens with: “AI-assisted development and vibe coding platforms turn this model inside out and are driving applications toward many small databases: one per user, application, preview environment, or tenant.” Its customer quote comes from Jon Abrams of OpenAI’s ChatGPT Sites team: “D1 gives our users a simple, scalable way to add features like custom metrics, leaderboards, and comments to their dashboards, games, and tools.” That is a vendor-chosen quote, so it shows who Cloudflare says the design is for, not how much of the load any one customer represents.
Creating a database: one checkpoint, new IDs on retry, and a 30-second cleanup
Creating a database means checking limits, minting a database UUID and a Durable Object ID, initializing the object, and writing one Postgres row that maps the two. The post makes that Postgres write the commit point: it is “where a database is considered created even if the final response fails to reach the user.” Everything after it is repairable. My summary of the failure paths, from the post’s steps:
| Step | What goes wrong | State left behind | What repairs it |
|---|---|---|---|
| 1. Limit check | The count comes from a replica read cached for “a few seconds,” so it can be stale | A few extra creations can be admitted (my inference) | Nothing stated |
| 2. Initialize the object under a new unique ID | Initialization fails | Nothing addressable | The whole sequence retries with new IDs |
| 3. Write the mapping row to Postgres | The write fails after the object was initialized | An orphan object that no row points to | Retry with new IDs; the orphan’s next heartbeat reports that its database does not exist and it marks itself deleted |
4. Confirm to the object, which updates KV (inside waitUntil()) |
The call fails | No KV entry yet | Reads fall back to Hyperdrive and a replica; the next heartbeat writes the KV entry |
Two decisions are worth separating. First, the post declines to wrap the flow in a single Postgres transaction because “it leads to connections being held while the Durable Object is being initialized, which could be on the other side of the world.” That is the blocking cost of a cross-store atomic commit, the trade-off our two-phase commit tutorial reproduces in a small lab. What D1 builds instead resembles a saga with no compensating actions: orphans are not undone by a coordinator, they are reaped by their own owner the next time it checks in. It also accepts a dual write and repairs it by reconciliation, where a transactional outbox would guarantee it.
Second, retries mint new identities. The post explains why reusing the database UUID with a fresh object ID is dangerous: “multiple Durable Objects would think they own the same database leading to difficult-to-debug situations.” With fresh IDs on every attempt, only the object whose ID reached Postgres is ever addressable, and the heartbeat turns the losers into tombstones. The post does not discuss the other common option, reusing both IDs so the retry is a no-op, which would trade orphans for an idempotent initialization.
The heartbeat is the repair loop for all of this. Each object contacts the control plane at an interval that “is 30 seconds with a per-database jitter on top,” with “knobs to change the interval by database or account, or disable heartbeats entirely.” The post’s summary: “Overall, the database heartbeats are the fallback mechanism for getting into a consistent state when operations need to write into an external datastore and also update the Durable Object itself.” Each 100,000 active databases that heartbeat once per interval would add about 3,300 requests a second (my arithmetic, ignoring jitter). The post says heartbeats “can tolerate slightly out-of-date data, meaning they can be served by Hyperdrive’s multiple caching layers or by a Postgres read replica,” which keeps that volume off the primary. The jitter is the usual defense against lockstep load, the same idea as the TTL jitter in our cache stampede tutorial, though the post does not say that is its purpose.
One timing detail is printed. The sample time wrangler d1 create run reports 1.614 seconds of wall time with 0.56 s of user and 0.13 s of system CPU. Adding those gives 0.69 s of the client’s own CPU, so at most about 0.9 s went to the network and the control plane (my arithmetic; the post gives no server-side latency). Part of the reason for the forwarding is stated: a round trip from afar “can be hundreds of milliseconds, whereas being near Postgres can be a few milliseconds,” so multi-query endpoints such as creation run near Postgres, while the object is initialized from near the user. Cloudflare’s Durable Objects docs say why that placement works: “By default, a Durable Object is instantiated in a data center close to where the initial get() request is made.”
Routing: Workers KV as the lookup, a Durable Object as its only writer
A query needs one fact before it can reach the right object: which Durable Object and namespace hold this database. The post keeps that mapping in Workers KV, “a durable datastore, not just a cache,” so that a miss at the edge falls back to KV’s own storage rather than Postgres. The failure it is guarding against is named: “In a catastrophic cache purge scenario, a million databases trying to fetch their routing information would lead to metastable failures.” Hyperdrive and the Postgres replicas remain the fallback “in rare cases” when the KV entry is missing altogether.
Cloudflare’s KV docs supply the number the post leaves out: “Changes may take up to 60 seconds or more to be visible in other global network locations as their cached versions of the data time out.” The same page recommends the post’s pattern to anyone who needs stronger ordering: “send all of your writes for a given KV key through a corresponding instance of a Durable Object, and then read that value from KV in other Workers.” That matches the post’s line that “the Durable Object acts as the serialization point for the writes to the Workers KV items.” So the routing design is the documented recipe, and its read-side staleness is a minute or more.
Staleness matters most on deletion. A deleted database can stay routable through stale KV entries in other locations, and the post’s answer is that the object remembers: “The Durable Object stores the active or deleted state in its storage too, so it can reject or accept query requests immediately, even if Workers KV is stale and allows requests through.” The refusal therefore happens at the object, after routing, rather than at the router.
Two smaller pieces of advice are worth keeping. Addressing Durable Objects by name “works great” in most cases and removes a lookup, and the post’s advice is to use that as a starting point and “only move to unique DO IDs if you have to.” And Hyperdrive’s query caching docs warn that it “does not purge or invalidate cached read query results when your application writes to your database,” with defaults of 60 seconds for max_age and 15 for stale_while_revalidate. Any read that must not be stale needs the uncached configuration, which fits the post’s second CRUD configuration, uncached and pointed at the primary.
Limits: where lag is the product
Quotas are where an eventually consistent control plane shows its price. The post handles storage and rows with two different pipelines.
Storage: a queue, a trigger, a delta log and a one-second job
Each Durable Object publishes its storage usage to “a Kafka-like queue.” Consumers are deliberately limited so they cannot overload Postgres, and the post says plainly that “it is fine if a load spike is not reflected immediately and our usage information is stale.” A consumer writes each database’s latest size into database_stats. Summing a whole account on every check is ruled out, because that query’s cost “scales linearly with the number of databases on each account.” So a trigger writes one delta row per change into account_database_stats_changes, and “a scheduled job runs every second” to fold the deltas into account_database_stats. The post prints the job’s statement:
WITH updates AS MATERIALIZED (
DELETE FROM account_database_stats_changes
RETURNING *
),
changes AS MATERIALIZED (
SELECT
account_id,
SUM(storage_change) AS storage
FROM updates
GROUP BY account_id
)
INSERT INTO account_database_stats (account_id, storage)
SELECT
account_id,
storage
FROM changes
ON CONFLICT (account_id) DO UPDATE
SET storage = account_database_stats.storage + EXCLUDED.storage;
Why not update the account total directly? The post’s answer is that it “would end up with a lot of write lock contention on a single row for large accounts.” And why does the statement work? In its words, “Postgres executes the data-modifying CTE exactly once and to completion.” Both claims are checkable, so I checked them.
What a local Postgres showed
The setup is PostgreSQL 18.6 for Windows (the embedded-binaries build from Maven Central), default READ COMMITTED isolation, fsync and synchronous_commit on, on a Windows machine with 14 logical CPUs, with the Python clients on the same machine. The three table names and the aggregation statement are the post’s. The column types and the trigger are my reconstruction, because the post describes the trigger only in prose:
CREATE TABLE database_stats (
database_id bigint PRIMARY KEY,
account_id bigint NOT NULL,
storage bigint NOT NULL
);
CREATE TABLE account_database_stats (
account_id bigint PRIMARY KEY,
storage bigint NOT NULL
);
CREATE TABLE account_database_stats_changes (
account_id bigint NOT NULL,
storage_change bigint NOT NULL
);
CREATE FUNCTION log_storage_delta() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
IF TG_OP = 'INSERT' THEN
INSERT INTO account_database_stats_changes VALUES (NEW.account_id, NEW.storage);
ELSE
INSERT INTO account_database_stats_changes VALUES (NEW.account_id, NEW.storage - OLD.storage);
END IF;
RETURN NEW;
END $$;
CREATE TRIGGER database_stats_delta
AFTER INSERT OR UPDATE OF storage ON database_stats
FOR EACH ROW EXECUTE FUNCTION log_storage_delta();
Test 1: a delta that lands between statements
A tempting simplification of the job is three statements in one transaction: read the sums, apply them, delete the log.
BEGIN; -- READ COMMITTED, the default
SELECT account_id, SUM(storage_change)
FROM account_database_stats_changes GROUP BY account_id; -- 1: read the sums
-- another session commits one more delta row here
INSERT INTO account_database_stats (account_id, storage) VALUES (...)
ON CONFLICT (account_id) DO UPDATE
SET storage = account_database_stats.storage + EXCLUDED.storage; -- 2: apply them
DELETE FROM account_database_stats_changes; -- 3: clear the log
COMMIT;
At the default isolation level each statement gets a fresh snapshot, so a delta committed between the first and third statement is deleted without ever being counted. I forced exactly that interleaving: the aggregator summed 300, a writer committed another 700, and the aggregator then deleted both rows.
READ COMMITTED summed= 300 delta rows left=0 account total=3,000,300 true total=3,001,000 drift=-700
REPEATABLE READ summed= 300 delta rows left=1 account total=3,000,300 true total=3,001,000 drift=-700
after the next pass: account total=3,001,000 drift=+0
In the first line the account total ends 700 short and nothing in this job will ever repair it. In the second line, at REPEATABLE READ, the same interleaving left the 700 row in the log and the next pass applied it, which is why the drift returns to zero. The single-statement form closes the gap without a stricter level, and the Postgres docs explain why concurrent runs of it do not double count. The transaction isolation docs say that “the would-be updater will wait for the first updating transaction to commit or roll back,” and that “if the first updater commits, the second updater will ignore the row if the first updater deleted it.” The WITH docs add that data-modifying statements there are “executed exactly once, and always to completion.”
Test 2: three aggregators that do not coordinate
Next I ran eight writer processes, each committing random size changes against 2,000 databases of one account, alongside three aggregator processes that did not coordinate with each other, for about six seconds. Then I drained the log and compared the account total with the true sum.
cte writers=8 aggregators=3 updates=213,315 aggregator runs=982 (consumed rows in 638) errors=0
account total=3,603,639,941 true total=3,603,639,941 drift=+0
three writers=8 aggregators=3 updates=214,233 aggregator runs=950 (consumed rows in 950) errors=0
account total=5,982,409,928 true total=3,610,676,577 drift=+2,371,733,351
The post’s statement stayed exact across 982 runs with no errors. The three-statement version, run the same way, ended 2.37 billion too high on a true total of 3.61 billion, about 66 percent, because overlapping runs read the same pending rows and each added them. So the same simplification fails in both directions: lost deltas with one aggregator, double counting with several. Six seconds is not a proof. The Postgres semantics quoted above are the reason it holds, and the second run shows the harness can see drift when there is some.
Test 3: one hot row against a delta log
Last, I compared two triggers on the same table. One updates the per-account total row in the same transaction, the design the post rejects. The other appends a delta row and leaves a once-a-second aggregator to fold it in. Writers run single-row autocommit updates against 10,000 databases of one account, and each cell is the median of three four-second runs.
| Writers | Hot row, tx/s | p50 ms | p99 ms | Delta log, tx/s | p50 ms | p99 ms | Delta log vs hot row |
|---|---|---|---|---|---|---|---|
| 1 | 5,363 | 0.18 | 0.41 | 5,376 | 0.18 | 0.42 | 1.0x |
| 4 | 10,766 | 0.31 | 1.10 | 17,028 | 0.22 | 0.37 | 1.6x |
| 16 | 7,390 | 1.24 | 12.59 | 45,309 | 0.32 | 0.81 | 6.1x |
| 48 | 7,252 | 4.35 | 33.38 | 59,323 | 0.61 | 3.17 | 8.2x |
With one writer the two are identical, at about 5,400 transactions a second. As writers are added, the hot-row version tops out between roughly 7,000 and 11,000 a second, and its 99th-percentile latency climbs from 0.41 ms to 33 ms, because every transaction queues for the same row lock until the previous one commits. The delta log keeps scaling, to about 59,000 a second at 48 writers with a 3 ms 99th percentile. The post’s lock-contention claim holds on this machine. The size of the gap is a local result: one node, one account, fast local storage, and clients sharing the CPUs. A consumer that batches many database updates into one transaction would take the lock less often per row, and a total split across several rows is another standard fix. I tested neither.
Rows: a ClickHouse query, a limiter and a Quicksilver key
The Free plan’s daily row limits use different parts. Each query’s rows read and written become a usage event in ClickHouse. A central component the post calls the “Limiter” “periodically queries the usage events” for accounts over a threshold. An account over its limit gets an email and a Quicksilver key that expires at the end of the day, and each Durable Object “periodically checks Quicksilver at the edge” and rejects queries while the key exists. The post gives no interval for any of those steps. The only bound on the unlock path comes from Cloudflare’s pricing docs: upgrading to the Workers Paid plan “will remove these limits, typically within minutes.”
Adding up what is stated
| Stage | What the post states | What it leaves out |
|---|---|---|
| Usage report to the queue | “batching and asynchronous processing” | Batch size, wait time, queue depth under load |
Queue consumer to database_stats |
Consumers are limited on purpose | The added lag when that limit binds |
| Delta fold | A job that “runs every second” | Nothing; this one is stated |
| Heartbeat | 30 seconds “with a per-database jitter on top” | The jitter range |
| Hyperdrive read | A cache TTL of “a few seconds” | Replica lag |
| Enforcement | Writes that increase size are forbidden once the total is over the limit | Nothing; this one is stated |
The stated parts are 30 seconds of heartbeat, 1 second of aggregation and a few seconds of cache: a little over half a minute before jitter, queue lag and replica lag. Overshoot is growth rate times lag. As an illustration only, an account growing by 100 MB a second would pass its limit by about 3.5 GB in 35 seconds, 0.35 percent of a 1 TB pool. That is small against the paid plan’s pool, so the post’s verdict that slower enforcement “is acceptable” looks fair for storage. It is a number worth writing down before copying the pattern for a quota where overshoot is expensive, such as a spend cap. The creation cap has the same property: the count comes from a cached replica read, so the databases-per-account limit is soft by the cache TTL (my inference from the post).
The storage read also has a neat trick with a cost. Hyperdrive’s default cache lifetime is 60 seconds and “applies to all queries,” so the team adds a throwaway dynamic_ttl parameter that changes every X seconds and produces “a different cache key every X seconds.” The account ID is the only other parameter, because adding the database ID would “make cache hit rates very poor.” The cost is that every window uses a fresh key, so the first reader in each window misses Hyperdrive’s cache layers and goes to a replica. If the cache behaves as the post describes, the replicas see on the order of one such read per account per window, not one per heartbeat. Per-database jitter spreads the heartbeats across time, while the window lines every reader up on the same key.
What a platform meets before the architecture
The post describes a control plane for “platforms with millions of created databases.” Cloudflare’s docs describe the account a new platform starts with, and the two do not line up until limits are raised.
| Limit | Default in the docs | At a million databases (my arithmetic) |
|---|---|---|
| Databases per account | 50,000 on Workers Paid and 10 on Free; raisable by request, with support for “millions to tens-of-millions of databases (or more) per account” | Reaching the post’s scale starts with a limit-increase request |
| Storage per account | 1 TB on Workers Paid and 5 GB on Free; raisable on request on paid plans | About 20 MB per database at 50,000 databases, about 1 MB at a million, unless raised |
| Size of one database | 10 GB, which “cannot be further increased” | The design intent is “horizontal scale out across multiple, smaller (10 GB) databases” |
| Throughput of one database | “inherently single-threaded”; 1 ms queries cap it near 1,000 a second | Parallelism comes from many databases, not one |
| API rate | “1,200 requests per five minute period per user”; Enterprise customers can ask Support to raise it | 4 requests a second, about 345,600 a day; a million creations take about 2.9 days on one user’s limit |
The sources are the D1 limits page (last updated April 21, 2026) and the API rate limits page (last updated August 25, 2026). The arithmetic is mine: it assumes one creation per API call, assumes D1 creation falls under the global limit (nothing I read says otherwise), and uses one user’s allowance, which Enterprise customers can ask Support to raise. Two gates arrive well before the heartbeat or the aggregation job matters: the 50,000-database default for a platform that grows steadily, and the API rate for one that provisions in bulk. The release notes also show the paid storage pool rose from 250 GB to 1 TB in July 2025, so the account-level number the post’s storage pipeline enforces has quadrupled in about fifteen months. When a single database is overloaded, the docs say it first queues requests and then returns an overloaded error, which is the bounded-line-then-reject behavior our backpressure tutorial builds by hand.
What to copy and what to measure first
- Write down three answers per piece of state. Name the source of truth, how stale a reader may be, and what repairs the difference. The post gives the first and third for creation, routing and limits, and half of the second.
- Fold logs in one statement. For any job that reads a log, applies it and deletes it, use a single statement as the post does, or a snapshot isolation level. The three-statement version drifted both ways in my runs.
- Keep hot totals off the write path. A delta log scaled 8.2 times better than a hot row at 48 writers on my machine. Measure your own pattern before choosing between it and a total split across rows.
- Total your lag budget before you promise a limit. Add the heartbeat, the job interval and the cache TTL, then measure queue depth, replica lag and jitter, and decide whether a cap that is soft by that many seconds is acceptable for that resource.
- Start with Durable Objects addressed by name. The post’s own advice is to move to unique IDs only when you must.
Cloudflare’s Artifacts makes a similar bet on small units elsewhere. Our look at Artifacts covered a repository per agent task, and our piece on GitHub’s Git rebuild covered another team treating durability and read scale as separate problems. The cost of multiplying units shows up in D1’s own pipelines: each unit adds a heartbeat, a usage report and a share of an account-level quota, and someone has to account for the lag in each.
What I tested and what I did not
I did not use a Cloudflare account and did not time anything on D1, Workers KV, Hyperdrive, Durable Objects or Quicksilver, so every statement about those comes from the post and Cloudflare’s docs, which I read on October 13, 2026. The Postgres figures come from one local server (PostgreSQL 18.6 on Windows, 14 logical CPUs) and show direction, not Cloudflare’s numbers. The trigger functions are my reconstruction. Anything labeled as my arithmetic uses only figures printed in the post or the docs.








No Comment! Be the first one.