Operating / External Databases and Hyperdrive

Why the Default Is Not External

The reasons the Durable Object stays the default store for a site, each with the limit or measurement behind it.

The Configuration Cap Bounds the Product Shape

A Hyperdrive configuration is per origin database. Free allows 10 per account and Paid 25, so a product that gives each site its own database tops out at 25 sites per Cloudflare account regardless of traffic. Sharing one configuration across every site means sharing ~20 origin connections on Free or ~100 on Paid, against a design where each site is an independently woken object that answers with no coordination.

A new site on the default backend costs one Durable Object and no account-level resource.

Every Statement Parks

The park suspends a PHP call, and it cannot suspend the host callback a statement is already executing inside. The external backend therefore yields each statement from PHP: CfwSqlClient hands it to the Worker, which performs it, and the call resumes with the result. Every statement is then a park and a network round trip, where the default backend answers it with a local read. A render issues 15 to 48 statements (see The Query Budget), and none of them can be deferred, because the render is waiting on the answer.

The Consistency Story Lives in the Object

One object per site is what makes the rest of the design work. The Durable Object gives strict serializability, which is what the generation counter, the resumable migration cursor in cfw_migrate, and the chunked restore in src/db/import-sql.ts are all built on. Each of those assumes a single writer whose transactions cannot interleave with another writer's.

Postgres behind Hyperdrive is a different concurrency model reached over a pooled connection from any isolate in any colo. Moving the data there removes the property the migration, the invalidation and the restore are written against.

The pooler runs in transaction mode, and Cloudflare documents three consequences that bear directly on Drupal. Session state does not persist, because connections are RESET when returned to the pool. A single Worker invocation may obtain several different connections. And wrapping multiple operations in one transaction to hold SET state is discouraged, since the connection cannot be reused while the transaction is open. Drupal opens transactions around entity saves, batch API runs and the installer.

The Query Cache Cannot Be Used for a CMS on the Same Database

Query caching is the latency lever, it is on by default, and it has no write invalidation: Cloudflare states that it does not purge cached read results when the application writes. Its own guidance is to create a second binding to the same database with --caching-disabled and route "authentication, sessions, permissions, billing state, admin settings, and reads immediately after a write" through it.

For Drupal that list is sessions, users_field_data, key_value, config, semaphore and every cache_* bin. A 60-second uninvalidated window over cache_config or semaphore is a correctness failure and not a staleness tradeoff. What remains after disabling the cache is connection pooling, which buys back a TCP, TLS and authentication handshake the default backend never pays.

Cache eligibility is also decided by text matching: both VOLATILE and STABLE PostgreSQL functions make a query uncacheable, and a function name such as NOW() appearing in a SQL comment is enough to trigger it.

Latency Moves From Zero to Regional

Cloudflare quantifies an uncached query at 20-30 ms from a distant region and 1-3 ms when the Worker is placed near the database, and says that multiple queries compound. A first render is 48 SQL statements and a repeat render 18, so a cold render would spend roughly 1 to 1.4 seconds in network alone at the distant figure, on top of the boot.

The documented remedy is placement.region, which pins the Worker to the database's cloud region. That is the opposite of SITE_LOCATION_HINT, a KV-overridable setting that places a site near its visitors. On the default backend those statements cost no network, because the storage is inside the object making the call.

An Open Socket Bills Duration

An outbound TCP connection held open inside a Durable Object blocks hibernation, defers eviction and accrues duration charges for up to 15 minutes per connection. The backend opens a connection for each statement and closes it, so no connection outlives a statement. A driver that held one across a render would make it a billed residency decision. src/ops/hibernation.ts transcribes the eligibility list.

The Query Budget

Hyperdrive on Free allows 100,000 database queries/day. A warm render costs 15 host statements, measured on a deployed throwaway with the page and dynamic_page_cache bins emptied and the rest warm (14 without the theme reset; 20 with bootstrap also emptied). So 100,000 queries is roughly 6,666 renders/day.

Regeneration on the free plan is bound by rows written at 10,866 renders/day with MEMORY_CACHE_BINS=none and 50,916 at the shipping default; bun scripts/measure/free-envelope.ts prints both. Hyperdrive's free query budget is below both, so it cannot raise the ceiling and would lower it. The serving ceiling is unaffected: an edge cache hit never reaches the object and issues no database query under either design, so 100,000 Worker requests/day still binds first.