Skip to content

Cloudflare Hyperdrive

OpenInsure uses Cloudflare Hyperdrive as the connection pooler + read-query cache between Cloudflare Workers and PlanetScale. Workers cannot maintain a persistent MySQL connection pool — every request is a fresh isolate. Hyperdrive solves that by keeping warm pools at every CF point of presence, multiplexing many Worker isolates onto shared MySQL connections, and caching eligible read queries.

A Worker isolate has no persistent connection pool. Without Hyperdrive, every cold start would have to TCP + TLS + MySQL-handshake to PlanetScale (~150–300 ms of latency on the critical path of every request). Hyperdrive makes that effectively zero: the Worker’s first query is served from a connection that was already warm in the same PoP.

Three things Hyperdrive does for us:

  1. Warm connection pools — pre-established TCP + TLS + MySQL sessions per region, ready for any Worker.
  2. Connection multiplexing — many Worker isolates share a single underlying MySQL connection.
  3. Read query caching — Hyperdrive parses queries, identifies read-only ones, and caches results for a configurable TTL. Writes invalidate.

It also automates credential management — the connection string lives in Hyperdrive’s config in Cloudflare’s control plane; the Worker only ever sees a binding object.

We expose one Hyperdrive binding per keyspace plus a legacy monolithic binding that still routes via the cutover routing rules:

BindingTargets keyspacePurpose
HYPERDRIVEai (all 5 via routing)Legacy / fallback during cutover
HYPERDRIVE_GLOBALoi_globalOrgs, users, programs, reference data
HYPERDRIVE_POLICIESoi_policiesPolicy lifecycle
HYPERDRIVE_SUBMISSIONSoi_submissionsQuote/bind workflow
HYPERDRIVE_CLAIMSoi_claimsClaim lifecycle
HYPERDRIVE_BILLINGoi_billingBilling, payments, GL

Each binding has its own connection pool, its own credentials, its own query cache, and its own monitoring. A bad query in one keyspace cannot exhaust connections in another.

The middleware in apps/api/src/middleware/database.ts injects all 6 onto every Hono context as db, globalDb, policiesDb, submissionsDb, claimsDb, billingDb. Routes pull what they need.

oi-sys-api has Cloudflare Smart Placement enabled ([placement] mode = "smart" in wrangler.toml). Cloudflare watches the Worker’s query patterns and reschedules it to run in the region closest to the most-queried Hyperdrive pool. Since all 6 of our Hyperdrives target us-east, the Worker effectively pins itself to a US-East PoP whenever DB latency is on the critical path.

This trades a tiny amount of edge-locality (the user might be served from us-east instead of their own PoP) for a much larger latency win on the database side. For an insurance API where almost every endpoint hits PlanetScale, that tradeoff is the right one.

Hyperdrive caches eligible read-only queries. The eligibility rules:

Cached:

  • Read-only SELECT statements that use only IMMUTABLE functions
  • Parameterized queries with literal arguments

Not cached (by Hyperdrive):

  • Mutating queries (INSERT, UPDATE, DELETE, UPSERT, REPLACE)
  • Queries that use STABLE functions (e.g., NOW(), CURRENT_TIMESTAMP, LAST_INSERT_ID(), LASTVAL())
  • Queries that use VOLATILE functions (e.g., RANDOM(), UUID())
  • Queries that use unsupported features (USE statements, multi-statement queries, certain auth plugins)

The fix for cached-by-default code that needs NOW() is to compute the value in the Worker and pass it as a parameter:

// Uncacheable
const policies = await policiesDb.execute(sql`
SELECT * FROM policies WHERE created_at > NOW() - INTERVAL 1 HOUR
`);
// Cacheable
const cutoff = new Date(Date.now() - 60 * 60 * 1000).toISOString();
const policies = await policiesDb.execute(sql`
SELECT * FROM policies WHERE created_at > ${cutoff}
`);

Each Hyperdrive binding declares a localConnectionString in apps/api/wrangler.toml so wrangler dev --local can talk to the docker-compose Vitess testserver instead of contacting Cloudflare:

[[hyperdrive]]
binding = "HYPERDRIVE_POLICIES"
id = "..." # production ID, ignored in --local mode
localConnectionString = "mysql://root:root@127.0.0.1:3306/openinsure"

Start the local Vitess: make vitess-up. The 5-keyspace topology mirrors production exactly.

The factory in packages/db/src/domain-clients.ts chooses one of two drivers based on the connection string format:

function buildDomainDb<T>(hd: HyperdriveBindingLike, schema: T) {
if (hd.connectionString.startsWith('mysql://')) {
// Local dev path: mysql2 driver
const pool = mysql.createPool(hd.connectionString);
return drizzleMysql2(pool, { schema, mode: 'planetscale' });
}
// Production path: @planetscale/database HTTP fetch driver
const client = new Client({ url: hd.connectionString });
return drizzlePlanetScale(client, { schema });
}
EnvironmentDriverUsed because
Workers@planetscale/database (HTTP fetch)No raw TCP from a Worker
Local devmysql2Talks to docker-compose Vitess testserver
Testsmysql2Same as local dev

Both go through Drizzle with mode: 'planetscale', so the query API is identical from the route’s perspective.

LimitValueNotes
Concurrent external conns per Worker request5 (Workers platform limit, not HD)Use max: 5 in mysql2/postgres pool config
Hyperdrive cache TTLconfigurable per binding (default ~60s)Set in dashboard or via wrangler hyperdrive update
Idle connection lifetime24 hours (PlanetScale-side)mysql2 should send keepalive
MySQL features unsupportedUSE statements, multi-statement queries, non-UTF8 charsets, certain auth pluginsIf you need them, bypass Hyperdrive

For PostgreSQL-specific unsupported features (LISTEN/NOTIFY, advisory locks, prepared-statement management), see the official Cloudflare docs — we don’t run Postgres so they don’t apply.

Database Schema

The 5 keyspaces, table-by-table, and how the schema is authored in Drizzle.

Database Migrations

Deploy-request workflow, branch lifecycle, and the migration TUI.

Local Development

Setting up the local Vitess testserver and running the API Worker locally.