Hyperdrive solves a real problem. Connection setup to Postgres is slow, Workers are short-lived, and a busy Worker will eat a database’s connection slots. You declare a binding, query through it, and Cloudflare handles pooling and connection reuse.
It assumes you have one database.
We give each organisation its own Postgres, with environments separated by schema inside it. One Worker, many databases, target picked per request. Hyperdrive can’t do that, and it took us a while to be sure it wasn’t user error.
Why it can’t
The configuration is declared in wrangler.toml:
[[env.prod.hyperdrive]]
binding = "CONTROL_DB_HYPERDRIVE"
id = "..."
env.CONTROL_DB_HYPERDRIVE resolves to one origin. Which origin was decided at deploy time.
There’s no runtime API to create a configuration or repoint an existing one. That follows from the design. Cloudflare pre-establishes the connection so your request doesn’t pay for it, which means the target has to be known before the request arrives.
For a tenant fleet you’d need a binding per customer, declared before that customer exists, plus a redeploy on every signup. We priced that out and stopped.
Two paths instead
Hyperdrive stays for the control plane. That’s the one platform database holding orgs, memberships, permissions and the routing table. Single stable origin, high query volume. Good fit.
Tenant queries resolve their own connection string per request:
const baseDbUrl = resolveDbConnectionRef(target.dbConnectionRef)
const tenantDbUrl = withDatabaseInPgUrl(baseDbUrl, target.database)
dbConnectionRef is a reference to a secret, not the secret. It resolves server-side, so a
connection string never reaches the browser or user-authored code.
We connect to Neon’s pooler directly on this path. That gives up Hyperdrive’s cached round-trips, which is a real latency cost. Neon’s pooler still handles the connection-slot problem, and that was the part we actually needed.
Two details mattered more than we expected.
Opening a client per query wastes time when one request hits tenant data four or five times. We keep
a per-request map of clients in AsyncLocalStorage, keyed by target, and close them when the request
ends. Connect timeout is 10 seconds so a slow origin can’t pin a Worker.
Actor context goes into the session rather than through application code. Audit triggers in the tenant database read it from connection settings:
current_setting('relpin.request_id', true)
current_setting('relpin.actor_user_id', true)
The trigger writes the audit row. Application code can’t skip it by forgetting a parameter. Worth doing on its own, and easier once you already own the connection.
The guard
Both paths are “a Postgres URL from config”. Point the tenant path at the Hyperdrive URL and nothing throws. You get control-plane data where tenant data should be, and it reads like a query bug.
So we check before connecting:
if (isHyperdriveUrl(baseDbUrl)) {
throw new TRPCError({
code: 'PRECONDITION_FAILED',
message: 'Tenant/org DB access must not use Hyperdrive. Configure an env-backed tenant admin URL instead.',
})
}
It fails closed and says what to do. We had this written down as a rule first. The rule lasted until someone was in a hurry.
If you’re doing anything similar: Hyperdrive is a deploy-time optimisation for a known origin. When the target depends on the request, you’re outside what it does, and two explicit paths beat one clever one. The guard is the part I’d copy.