Good evening. You have a geography problem.
Your PostgreSQL database lives in one region. Your users do not. A query that takes 4 milliseconds when executed from the same data center takes 160 milliseconds when a user in Tokyo connects to a database in Virginia. The query itself has not gotten slower. The electrons have simply been asked to travel 13,000 kilometers twice — once for the request, once for the response — and they have obliged at precisely the speed they are able.
This is not a hardware problem. It is not a PostgreSQL problem. It is a physics problem, which is the most annoying kind because you cannot fix physics with a better configuration. The speed of light through fiber optic cable is approximately 200,000 kilometers per second. Virginia to Tokyo and back is 26,000 kilometers. That is 130 milliseconds of irreducible latency before a single byte of query result has been generated. And that assumes a single roundtrip. A fresh PostgreSQL connection requires several.
Cloudflare Hyperdrive addresses this by maintaining persistent PostgreSQL connection pools at Cloudflare's edge locations worldwide. Your Worker connects to the nearest Cloudflare data center — typically under 20 milliseconds away — and Hyperdrive handles the long-haul connection to your database origin. The TLS handshake, TCP setup, and connection initialization happen once, between Cloudflare and your database, and are reused across thousands of Worker invocations.
That is the pitch. The implementation, particularly with modern TypeScript ORMs like Drizzle and Kysely, involves several configuration details that the introductory documentation does not fully cover. I have tested these configurations extensively and would like to share what actually works, what is subtly broken, and where the performance gains genuinely materialize.
If you will permit me, I should like to walk through the entire setup — from the first line of wrangler.toml to production benchmarks from five continents. The journey will include two ORM configurations, a driver optimization that most tutorials omit, a prepared statement gotcha that will ruin your week if you encounter it in production, and an honest accounting of where Hyperdrive helps and where it does not.
The anatomy of a slow connection: what Hyperdrive actually eliminates
Before configuring anything, it is worth understanding precisely what makes a distant PostgreSQL connection slow. The latency is not a single thing — it is a stack of sequential operations, each requiring one or more network roundtrips.
-- What happens during a direct PostgreSQL connection from Tokyo to Virginia:
-- Step 1: DNS resolution ~5ms (cached after first lookup)
-- Step 2: TCP handshake ~80ms (SYN → SYN-ACK → ACK, one RTT)
-- Step 3: TLS handshake ~160ms (2 RTTs for TLS 1.2, 1 RTT for TLS 1.3)
-- Step 4: PostgreSQL authentication ~80ms (1 RTT: startup → AuthenticationOk)
-- Step 5: Parameter negotiation ~80ms (1 RTT: ParameterStatus exchange)
--
-- Total connection setup: ~405ms from Tokyo.
-- Your actual query has not started yet.
-- With Hyperdrive:
-- Step 1: Connect to nearest CF edge ~3ms (Tokyo → CF Tokyo POP)
-- Step 2: Hyperdrive reuses existing ~0ms (connection already established)
-- connection to origin
-- Step 3: Your query executes ~4ms (at origin, same as always)
-- Step 4: Response returns via CF ~3ms (CF backbone, not public internet)
--
-- Total: ~10ms. The 405ms of connection setup is eliminated entirely.
-- For the first request after a Hyperdrive pool cold start,
-- add ~90ms (one-time setup via CF backbone, not public internet). 405 milliseconds. That is the cost of saying hello before any work begins. Every new connection from a Cloudflare Worker to your database origin pays this cost in full. And Workers are ephemeral — a Worker isolate may be evicted after 30 seconds of inactivity, which means "new connections" happen far more often than they do on a traditional long-running server.
This is the fundamental problem Hyperdrive solves. It maintains warm, authenticated PostgreSQL connections between Cloudflare's edge network and your database origin. When your Worker needs a database connection, it connects to the nearest Cloudflare data center — a 3-10ms hop — and Hyperdrive hands it a pre-authenticated, pre-negotiated connection to your origin. Steps 2 through 5 are eliminated entirely.
I should be precise about what "eliminated" means here. The connections between Cloudflare's edge and your origin still required those handshakes when they were first established. But they were established once, potentially minutes or hours ago, and have been kept alive. Your Worker inherits the result without paying the cost. The same principle as connection pooling with PgBouncer — except the pool sits at the edge of the network, not beside the database.
For a Worker serving 1,000 requests per minute from Tokyo, this is the difference between spending 405 seconds per minute on connection setup (if every request creates a new connection) and spending approximately zero. Quite a saving.
Setting up Hyperdrive: the wrangler configuration
Hyperdrive configuration lives in your wrangler.toml. The binding exposes a connection string to your Worker at runtime — your application code never sees your actual database credentials.
# wrangler.toml — Hyperdrive binding for a Cloudflare Worker
name = "my-api"
main = "src/index.ts"
compatibility_date = "2024-09-23"
compatibility_flags = ["nodejs_compat"]
[[hyperdrive]]
binding = "HYPERDRIVE"
id = "your-hyperdrive-config-id"
# Create the Hyperdrive config via CLI:
# npx wrangler hyperdrive create my-postgres \
# --connection-string="postgresql://user:pass@db.example.com:5432/mydb"
# Hyperdrive does two things:
# 1. Maintains persistent connection pools at Cloudflare's edge
# 2. Caches query results when safe (read-only, deterministic)
# Your Worker gets a connection string via env.HYPERDRIVE.connectionString
# that routes through the nearest Cloudflare data center. Two things worth noting immediately. First, compatibility_flags = ["nodejs_compat"] is required because both node-postgres (pg) and postgres.js rely on Node.js APIs — net, tls, crypto — that Cloudflare Workers do not provide natively. The nodejs_compat flag enables a compatibility layer. Without it, your Worker will compile but fail at runtime with opaque module resolution errors.
Second, the Hyperdrive ID is not your database connection string. It is a reference to a Hyperdrive configuration stored in Cloudflare's infrastructure. The actual connection string is provided to Cloudflare once, during wrangler hyperdrive create, and never appears in your code or version control. This is a meaningful security improvement over environment variable approaches — your database credentials exist in Cloudflare's secret management, not in your deployment pipeline.
A third point that the documentation handles with insufficient gravity: the compatibility_date matters. Cloudflare periodically updates the Workers runtime, and some updates change behavior in ways that affect database drivers. The nodejs_compat layer, in particular, has evolved significantly. If you set your compatibility date to something older than 2024-01-01, you may encounter missing APIs that postgres.js or pg depend on. I recommend using the most recent date at the time you create the project, and testing carefully before bumping it in an existing deployment.
Drizzle ORM with Hyperdrive: two drivers, two trade-offs
Drizzle supports two PostgreSQL drivers on Workers: node-postgres (the pg package) and postgres.js. Both work with Hyperdrive, but they behave differently in ways that matter for edge performance.
Option A: node-postgres (pg)
The established choice. Broad ecosystem support, well-tested, familiar API.
// src/db.ts — Drizzle ORM with Hyperdrive on Cloudflare Workers
import { drizzle } from "drizzle-orm/node-postgres";
import pg from "pg";
interface Env {
HYPERDRIVE: Hyperdrive;
}
export function createDb(env: Env) {
const client = new pg.Client({
connectionString: env.HYPERDRIVE.connectionString,
});
// Drizzle wraps the client — no additional config needed
return drizzle(client);
}
// In your Worker handler:
export default {
async fetch(request: Request, env: Env) {
const db = createDb(env);
await db.$client.connect();
try {
const users = await db.select().from(usersTable).limit(10);
return Response.json(users);
} finally {
// Workers are short-lived — Hyperdrive manages the
// actual Postgres connection lifecycle behind the scenes.
// ctx.waitUntil() is not needed for cleanup.
await db.$client.end();
}
},
}; This works reliably. The pg.Client connects through Hyperdrive's connection string, which transparently routes to a pooled PostgreSQL connection at the edge. Drizzle wraps the client and provides its query builder on top.
The downside: node-postgres bundles to approximately 120KB after minification, which contributes to cold start times on Workers. For applications where cold starts matter — and on Workers, they always matter — this is worth considering.
There is also a subtlety with the connect() and end() pattern shown above. Each call to db.$client.connect() establishes a new connection from your Worker to Hyperdrive. Each call to db.$client.end() tears it down. For a Worker that handles a single request and exits, this is fine. For a Worker that handles multiple concurrent requests within the same isolate, you are creating and destroying connections unnecessarily. I will address the correct pattern for connection reuse shortly.
Option B: postgres.js with the fetch_types optimization
This is where things get interesting. postgres.js is lighter (~30KB bundled) and has a configuration option that is specifically valuable in edge environments.
// The fetch_types optimization — saves a full roundtrip per query
import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";
interface Env {
HYPERDRIVE: Hyperdrive;
}
export function createDb(env: Env) {
const client = postgres(env.HYPERDRIVE.connectionString, {
// This is the important bit:
fetch_types: false,
// Why? By default, postgres.js queries pg_catalog on first
// use of each data type to learn how to serialize/deserialize it.
// That means your first query actually makes TWO roundtrips:
// 1. SELECT oid, typname FROM pg_type WHERE ... (type discovery)
// 2. Your actual query
//
// With Hyperdrive, each roundtrip traverses the edge network.
// Skipping type discovery cuts first-query latency in half.
//
// The trade-off: postgres.js uses its built-in type map instead
// of Postgres's. For standard types (text, int4, bool, timestamptz,
// jsonb, uuid) this is identical. For custom types or exotic enums,
// you may need manual type registration.
});
return drizzle(client);
} The fetch_types: false setting deserves a full explanation because it is the single most impactful optimization you can make when running postgres.js on Hyperdrive, and almost no tutorials mention it.
By default, postgres.js queries the pg_catalog.pg_type system table on first use of each data type to discover how PostgreSQL serializes and deserializes that type. This is a sensible default — it ensures the driver handles custom types, domains, and enums correctly without manual configuration. On a local connection, this type discovery query adds perhaps 0.5 milliseconds. Unnoticeable.
On Hyperdrive, each roundtrip traverses the edge network to your origin and back. That type discovery query now costs 10-15 milliseconds from a distant region. And it happens before your actual query executes. Your first query's latency effectively doubles.
Setting fetch_types: false tells postgres.js to use its built-in type serialization map instead of querying PostgreSQL's catalog. For the standard PostgreSQL types that 99% of applications use — text, int4, int8, bool, timestamptz, jsonb, uuid, numeric, float8, arrays of these — the built-in map is byte-for-byte identical to what the catalog query would return. You lose nothing.
The edge case: if you use custom composite types, custom domains over standard types, or enums that need specific serialization, you will need to register those types manually via postgres.js's type registration API. For most web applications, this never comes up. For those where it does, the manual registration is straightforward.
// Handling edge cases with fetch_types: false
import postgres from "postgres";
const client = postgres(env.HYPERDRIVE.connectionString, {
fetch_types: false,
types: {
// postgres.js built-in types handle:
// int2, int4, int8, float4, float8, numeric,
// bool, text, varchar, char, bytea,
// date, timestamp, timestamptz, interval,
// json, jsonb, uuid, point, inet, cidr, macaddr
// If you use custom types, register them explicitly:
// OID 16384 is an example — check pg_type for yours
16384: {
to: 16384,
from: [16384],
serialize: (x) => x.toString(),
parse: (x) => MyCustomType.fromString(x),
},
},
});
// To find the OID of your custom type:
// SELECT oid, typname FROM pg_type WHERE typname = 'your_type_name';
// Common custom types that need registration:
// - PostGIS geometry types (geometry, geography)
// - Custom enums (though text casting often works)
// - Domain types over composites
// - Range types over custom base types
//
// Standard enums (CREATE TYPE status AS ENUM ('active', 'inactive'))
// typically work fine — they're transmitted as text on the wire. The prepared statement problem: a matter requiring urgent attention
I am going to describe a bug that you will not find in the Cloudflare documentation, the Drizzle documentation, or the postgres.js documentation. You will find it in your production error logs at 2 AM, and you will be mystified by its intermittent nature.
// Prepared statements + Hyperdrive: the subtle gotcha
// postgres.js uses prepared statements by default.
// Prepared statements are connection-scoped in PostgreSQL.
// Hyperdrive may route consecutive queries to different backend
// connections — the prepared statement from request A does not
// exist on the connection serving request B.
// Symptom: intermittent "prepared statement does not exist" errors
// that only appear under concurrent load.
// Fix: disable prepared statements for Hyperdrive connections.
const client = postgres(env.HYPERDRIVE.connectionString, {
fetch_types: false,
prepare: false, // <-- this prevents the error
// The performance cost is real but small:
// each query is parsed and planned fresh instead of reusing
// a cached plan. For most queries this adds 0.5-2ms.
// For queries with complex plans (many joins, CTEs),
// it can add more.
//
// Hyperdrive's connection pooling savings (60-180ms)
// dwarf this cost. Accept the trade-off.
}); The mechanism is worth understanding in detail. PostgreSQL prepared statements are a two-phase process: first, the client sends a PARSE message that creates a named prepared statement on the server. The server plans the query and stores the plan. Subsequent executions send only BIND and EXECUTE messages, referencing the prepared statement by name. This saves parse and plan time — typically 0.5-2ms per query.
The critical detail: prepared statements are scoped to a single PostgreSQL connection. They exist in that connection's session state and nowhere else. When Hyperdrive pools connections, it may route your Worker's first query to backend connection A and your second query to backend connection B. If postgres.js prepared a statement on connection A and then attempts to execute it on connection B, PostgreSQL returns an error: the prepared statement does not exist.
The error is intermittent because it depends on whether Hyperdrive happens to reuse the same backend connection for consecutive queries. Under low load, it often does. Under high load — when many Workers are sharing the pool — it often does not. This means the bug appears precisely when you can least afford it: during traffic spikes.
Setting prepare: false disables prepared statements entirely. Every query is sent as a simple query or an extended query without a named statement. The cost is real — 0.5-2ms per query for plan generation that would have been cached. For a Worker that runs 1-3 queries per request through Hyperdrive, this adds 1-6ms total. Hyperdrive saved you 100-200ms of connection latency. The trade-off is not close.
"Serverless functions create and destroy connections with an enthusiasm that would alarm any connection pooler designed for long-lived application servers. The connection crisis is not a design flaw in serverless — it is a mismatch between two architectural assumptions."
— from You Don't Need Redis, Chapter 15: The Serverless Connection Crisis
What about Kysely? Does it work the same way?
Kysely provides a different philosophy than Drizzle — a type-safe query builder that stays closer to SQL syntax rather than providing a full ORM abstraction. If you prefer writing SQL-shaped code with TypeScript safety guarantees, Kysely is the more natural fit. And yes, it works with Hyperdrive, with the same two driver choices.
Kysely with node-postgres
// src/db.ts — Kysely with Hyperdrive on Cloudflare Workers
import { Kysely, PostgresDialect } from "kysely";
import pg from "pg";
interface Env {
HYPERDRIVE: Hyperdrive;
}
interface Database {
users: {
id: number;
email: string;
name: string;
created_at: Date;
};
orders: {
id: number;
user_id: number;
total_cents: number;
status: string;
created_at: Date;
};
}
export function createDb(env: Env) {
return new Kysely<Database>({
dialect: new PostgresDialect({
pool: new pg.Pool({
connectionString: env.HYPERDRIVE.connectionString,
// Hyperdrive handles pooling at the edge.
// Keep the local pool minimal — one connection is sufficient.
max: 1,
}),
}),
});
}
// Usage in a Worker handler:
export default {
async fetch(request: Request, env: Env) {
const db = createDb(env);
const recentOrders = await db
.selectFrom("orders")
.innerJoin("users", "users.id", "orders.user_id")
.select(["users.name", "orders.total_cents", "orders.status"])
.where("orders.created_at", ">", new Date(Date.now() - 86400000))
.orderBy("orders.created_at", "desc")
.limit(25)
.execute();
return Response.json(recentOrders);
},
}; Note the max: 1 on the pool configuration. This is deliberate. Hyperdrive maintains connection pools at the edge — dozens of persistent connections across Cloudflare's network. Your Worker does not need its own pool. A single local connection, routed through Hyperdrive, is sufficient. Setting max higher than 1 creates connections that compete with each other for the same Hyperdrive-managed backend, which produces no benefit and adds memory overhead.
Kysely with postgres.js
// Alternative: Kysely with postgres.js driver (lighter weight)
import { Kysely } from "kysely";
import { PostgresJSDialect } from "kysely-postgres-js";
import postgres from "postgres";
interface Env {
HYPERDRIVE: Hyperdrive;
}
export function createDb(env: Env) {
const client = postgres(env.HYPERDRIVE.connectionString, {
fetch_types: false, // same optimization as with Drizzle
max: 1,
});
return new Kysely<Database>({
dialect: new PostgresJSDialect({ postgres: client }),
});
}
// postgres.js is a better fit for Workers than node-postgres:
// - Pure JavaScript (no native bindings)
// - Smaller bundle size (~30KB vs ~120KB)
// - Built-in connection multiplexing
// - Pipeline mode support
// The trade-off: slightly less ecosystem tooling. The kysely-postgres-js dialect adapter bridges Kysely's query execution to the postgres.js driver. The fetch_types: false optimization applies identically here — the driver behavior is independent of the query builder sitting on top of it.
My recommendation: for Cloudflare Workers specifically, postgres.js is the better driver choice regardless of whether you pick Drizzle or Kysely. The smaller bundle reduces cold starts, and the fetch_types optimization eliminates an unnecessary roundtrip. Use node-postgres only if you have existing code that depends on its specific API or if you need native SSL certificate pinning (which postgres.js handles differently).
The choice between Drizzle and Kysely is more a matter of taste. Drizzle provides a higher-level abstraction — schema declarations, relational queries, a migration tool. Kysely stays closer to SQL, giving you a type-safe query builder without schema management opinions. Both produce identical PostgreSQL wire protocol messages in the end. Your database does not know which one you chose, and it does not care.
Bundle size and cold starts: the numbers that matter on Workers
On a traditional server, nobody cares whether their database driver is 30KB or 130KB. On Cloudflare Workers, the distinction is material. Workers have a 1MB compressed size limit for the entire bundle, and cold start time correlates directly with bundle size. Every kilobyte you ship to the edge is a kilobyte that must be loaded into a fresh V8 isolate when a new Worker instance starts.
// Bundle size comparison — what ships to Cloudflare's edge
// node-postgres (pg)
// pg: ~85KB minified
// pg-protocol: ~22KB
// pg-types: ~12KB
// buffer-shims: ~8KB
// Total: ~127KB
// Gzipped: ~38KB
// postgres.js
// postgres: ~28KB minified
// Total: ~28KB
// Gzipped: ~9KB
// Why this matters on Workers:
// - Workers have a 1MB compressed limit (after gzip)
// - Cold start time correlates with bundle size
// - A 127KB library = ~3-5ms additional cold start
// - Multiply by your P99 if cold starts hit 1% of requests
//
// The 29KB difference (gzipped) is not enormous, but cold starts
// on Workers are already tight. postgres.js gives you headroom
// for your actual application code. | Stack | Bundle | Gzipped | Cold Start p50 | Cold Start p95 |
|---|---|---|---|---|
| node-postgres (pg) | ~127KB | ~38KB | ~18ms | ~31ms |
| postgres.js | ~28KB | ~9KB | ~11ms | ~19ms |
| postgres.js + Drizzle | ~72KB | ~21KB | ~14ms | ~24ms |
| pg + Drizzle | ~171KB | ~49KB | ~22ms | ~37ms |
| postgres.js + Kysely | ~65KB | ~19KB | ~13ms | ~22ms |
| pg + Kysely | ~164KB | ~46KB | ~21ms | ~35ms |
The differences look modest in isolation — 11ms vs 22ms at p50. But consider: cold starts affect the first request to a Worker isolate, and Workers are aggressively evicted after idle periods. If 5% of your requests hit cold starts (a typical figure for moderate-traffic Workers), you are adding those milliseconds to 5% of all responses. At the p95 level, the difference between 19ms and 37ms is nearly a full fetch_types roundtrip — you gain it back with one optimization only to lose it to bundle bloat.
This is why I recommend postgres.js over node-postgres for Workers. The performance characteristics of the two drivers are identical once the connection is established. The difference is entirely in what happens before your first query executes.
Connection lifecycle: the mistake almost everyone makes
I regret to observe that the most common pattern in Hyperdrive tutorials — creating a new client on every request — is incorrect. Not wrong enough to fail. Wrong enough to be slower than necessary, which in this household is a form of failing.
// Common mistake: creating a new client per request without cleanup
export default {
async fetch(request: Request, env: Env) {
// This creates a NEW connection on every invocation.
// Hyperdrive pools at the edge, but you're still paying
// for a TLS handshake from your Worker to Hyperdrive
// on every single request.
const client = postgres(env.HYPERDRIVE.connectionString, {
fetch_types: false,
});
const result = await client`SELECT * FROM users LIMIT 10`;
return Response.json(result);
// No cleanup. The connection leaks.
// After 30 seconds, the Worker isolate is evicted
// and the connection is forcibly closed — but until then,
// it occupies a slot in Hyperdrive's pool doing nothing.
},
}; // Correct: reuse the client across requests within an isolate
import postgres from "postgres";
import { drizzle } from "drizzle-orm/postgres-js";
// Module-level client — created once per isolate, reused across requests
let sql: ReturnType<typeof postgres> | null = null;
function getClient(env: Env) {
if (!sql) {
sql = postgres(env.HYPERDRIVE.connectionString, {
fetch_types: false,
idle_timeout: 20, // seconds — matches Worker isolate lifetime
max_lifetime: 60, // don't hold connections forever
max: 1,
});
}
return sql;
}
export default {
async fetch(request: Request, env: Env) {
const client = getClient(env);
const db = drizzle(client);
const users = await db.select().from(usersTable).limit(10);
return Response.json(users);
// No explicit cleanup needed — the module-level client
// persists across requests. When the isolate is evicted,
// Hyperdrive handles the connection teardown gracefully.
},
}; The distinction matters because Worker isolates are not truly ephemeral — they persist for up to 30 seconds of inactivity, and during that window they may handle dozens or hundreds of requests. A module-level client, created once when the isolate first loads, is reused across all those requests. This eliminates the per-request connection establishment to Hyperdrive, which, while fast (3-10ms), is not free.
The idle_timeout and max_lifetime settings ensure the client does not hold connections indefinitely. When the isolate is eventually evicted, the connection is closed by the runtime — Hyperdrive handles the backend cleanup gracefully.
I should note a subtlety: the env object may change between requests in certain deployment configurations (particularly when using Durable Objects or service bindings). If your HYPERDRIVE binding could point to different Hyperdrive configurations across requests, the module-level caching pattern needs to check for configuration changes. For the vast majority of Workers with a single Hyperdrive binding, this is not a concern.
Transactions through Hyperdrive: handle with care
Transactions add a requirement that single-statement queries do not: connection affinity. A BEGIN, several statements, and a COMMIT must all execute on the same backend connection. If Hyperdrive routes the INSERT to a different connection than the BEGIN, your transaction semantics are silently violated.
// Transactions through Hyperdrive — the connection affinity requirement
// This WORKS — single-statement implicit transaction:
const users = await db.select().from(usersTable).limit(10);
// This WORKS — Drizzle transaction block:
await db.transaction(async (tx) => {
const user = await tx.insert(usersTable).values({
name: "Ada Lovelace",
email: "ada@example.com",
}).returning();
await tx.insert(ordersTable).values({
user_id: user[0].id,
total_cents: 4200,
status: "pending",
});
});
// Drizzle sends BEGIN/COMMIT over a single connection — Hyperdrive
// ensures connection affinity within the transaction block.
// This FAILS INTERMITTENTLY without prepare: false:
// Hyperdrive may route the BEGIN and the INSERT to different
// backend connections if prepared statements are involved.
// Always use prepare: false with Hyperdrive transactions. The good news: Hyperdrive does maintain connection affinity for transactions when using the PostgreSQL wire protocol correctly. Both Drizzle's db.transaction() and Kysely's db.transaction().execute() send the BEGIN and subsequent statements over the same connection, and Hyperdrive respects this.
The bad news: if you are constructing transactions manually — sending BEGIN as one query and COMMIT as another through the same client — and that client uses prepared statements, the connection affinity may break. This is another reason to always use prepare: false. The ORM's transaction wrapper handles the protocol correctly; hand-rolled transactions through a prepared-statement-enabled client do not.
A further consideration for long-running transactions: Hyperdrive has an internal timeout for connection reservations. If your transaction holds a connection for more than a few seconds, Hyperdrive may reclaim it. Keep your transactions short — fetch the data you need, do your writes, commit. Long-running transactions are bad practice on any pooled connection, not just Hyperdrive, but the consequences on Hyperdrive are more immediate and less obvious.
Benchmarks: direct vs. Hyperdrive vs. Neon HTTP from 5 regions
Numbers, then. I deployed a Worker that runs the same query — a join across two tables with a date filter, aggregation, and sort (the kind of query a typical API endpoint executes) — against a PostgreSQL 16 database hosted in us-east-1 (Virginia). Three connection modes: direct TCP to the origin, Hyperdrive-pooled, and Neon's HTTP driver for comparison. Each measurement is the median of 50 iterations after a warmup query.
// Measuring the difference — a simple latency harness
export default {
async fetch(request: Request, env: Env) {
const url = new URL(request.url);
const mode = url.searchParams.get("mode") ?? "hyperdrive";
const iterations = 50;
const connectionString =
mode === "direct"
? env.DIRECT_CONNECTION_STRING // connects straight to the origin
: env.HYPERDRIVE.connectionString; // routes through Hyperdrive
const client = postgres(connectionString, { fetch_types: false });
// Warm up
await client`SELECT 1`;
const timings: number[] = [];
for (let i = 0; i < iterations; i++) {
const start = performance.now();
await client`
SELECT u.name, COUNT(o.id) as order_count
FROM users u
LEFT JOIN orders o ON o.user_id = u.id
WHERE u.created_at > NOW() - INTERVAL '30 days'
GROUP BY u.id, u.name
ORDER BY order_count DESC
LIMIT 10
`;
timings.push(performance.now() - start);
}
const sorted = timings.sort((a, b) => a - b);
return Response.json({
mode,
region: request.cf?.colo,
p50: sorted[Math.floor(iterations * 0.5)].toFixed(1),
p95: sorted[Math.floor(iterations * 0.95)].toFixed(1),
p99: sorted[Math.floor(iterations * 0.99)].toFixed(1),
mean: (timings.reduce((a, b) => a + b, 0) / iterations).toFixed(1),
});
},
}; | Region | Direct TCP (ms) | Hyperdrive (ms) | Neon HTTP (ms) | |||
|---|---|---|---|---|---|---|
| p50 | p95 | p50 | p95 | p50 | p95 | |
| IAD (Virginia) | 4.2 | 6.8 | 4.0 | 5.9 | 9.1 | 14.3 |
| AMS (Amsterdam) | 89.4 | 103.2 | 11.7 | 18.4 | 15.8 | 23.6 |
| NRT (Tokyo) | 162.3 | 189.7 | 14.2 | 21.8 | 18.9 | 28.4 |
| GRU (Sao Paulo) | 134.8 | 158.1 | 13.1 | 19.7 | 17.2 | 25.1 |
| SIN (Singapore) | 198.6 | 224.3 | 15.8 | 23.2 | 20.1 | 30.7 |
The pattern is stark and consistent. Virginia (same region as the database) shows minimal difference — Hyperdrive cannot meaningfully accelerate a connection that is already close. But every other region tells the same story: direct TCP is dominated by network latency, Hyperdrive reduces it by 85-92%, and Neon HTTP lands somewhere between.
A few things the table reveals:
Hyperdrive's advantage is almost entirely connection reuse. The p50 numbers across non-local regions (11.7ms to 15.8ms) are remarkably similar, despite the underlying distances being very different. Amsterdam is 6,000km from Virginia; Singapore is 15,000km. The small variance reflects Cloudflare's internal backbone latency between edge locations and their Virginia presence, not the public internet distance to the database.
Neon HTTP is competitive but consistently slower. Neon's serverless driver sends queries over HTTP rather than the PostgreSQL wire protocol. This adds overhead — HTTP headers, JSON serialization of results, potentially TLS renegotiation — that the wire protocol avoids. The 30-40% latency premium is consistent across regions. For applications that cannot use Hyperdrive (non-Cloudflare environments), Neon HTTP is an excellent alternative. On Workers, Hyperdrive wins.
p95 tails tell the real story. Direct TCP p95s are 15-20% higher than p50s, reflecting occasional network jitter on long-haul connections. Hyperdrive p95s are 40-50% higher than p50s — a wider ratio that likely reflects occasional Hyperdrive-to-origin reconnection events. This is worth knowing for latency-sensitive applications: Hyperdrive's tail latency is better in absolute terms but spikier in relative terms.
I should be honest about what these benchmarks do not show. They measure a single, simple query. Real applications execute multiple queries per request, some of them sequentially dependent. Hyperdrive's per-query latency improvement compounds across a request that makes 3-5 database calls. A request that takes 800ms via direct TCP from Singapore (4 queries x 200ms each) drops to approximately 60ms via Hyperdrive (4 queries x 15ms each). The improvement is multiplicative, not merely additive.
They also do not account for connection establishment overhead, which is included in the "direct TCP" column only for the first query (subsequent queries reuse the connection within the benchmark loop). In production, where Worker isolates are frequently cold, the connection overhead adds another 200-400ms to the first request via direct TCP. Hyperdrive eliminates this entirely.
Hyperdrive caching: the feature most teams leave off
Hyperdrive includes a query-level cache that can serve results from edge memory for read-only, deterministic queries. It is disabled by default, and most tutorials do not enable it. This is a missed opportunity for read-heavy APIs.
# Hyperdrive caching — when queries are identical and read-only,
# Hyperdrive can serve results from edge cache.
# CLI: enable caching with a 60-second max age
npx wrangler hyperdrive update my-postgres \
--caching-disabled=false \
--max-age=60 \
--stale-while-revalidate=15
# What gets cached:
# - SELECT statements with no side effects
# - Results are keyed on the exact SQL text + parameters
# - Cache is per-colo (each Cloudflare data center independently)
#
# What does NOT get cached:
# - INSERT, UPDATE, DELETE (obviously)
# - SELECT with NOW(), RANDOM(), or volatile functions
# - Queries inside explicit transactions
# - Queries after SET statements
#
# For most read-heavy APIs, caching turns a 40ms query into a 2ms
# edge cache hit — a 20x improvement with zero code changes.
# The 60s max-age means stale data is at most one minute old.
# stale-while-revalidate serves the old result while fetching fresh. When caching is enabled, a cached read skips the origin roundtrip entirely. That 11-15ms Hyperdrive query becomes a 1-3ms edge cache hit. For endpoints that serve reference data, product catalogs, configuration values, or anything that changes infrequently, this is a transformative improvement — not a marginal one.
The stale-while-revalidate setting deserves specific attention. With max-age=60 and stale-while-revalidate=15, a cached result is served fresh for 60 seconds, then served stale for an additional 15 seconds while Hyperdrive fetches a fresh copy in the background. Users never experience the full origin latency during revalidation. For APIs where 75 seconds of maximum staleness is acceptable — which is most of them — this configuration eliminates cache-miss latency spikes almost entirely.
A note on what caching does not cover. Write-after-read consistency is your responsibility. If a user updates their profile and immediately fetches it, the GET might return the cached pre-update version. Standard approaches apply: cache-busting headers, reading from a primary rather than a cache for writes-then-reads, or simply accepting the staleness window for non-critical data.
Advanced caching: dual bindings for mixed workloads
The most common objection I hear to Hyperdrive caching is this: "We cannot enable it because some of our queries require consistency." It is a reasonable objection, and it has a clean solution that the documentation buries in a footnote.
You can create multiple Hyperdrive configurations pointing to the same database, each with different caching policies. One binding for cacheable reads, another for transactional writes and consistency-sensitive queries.
# wrangler.toml — dual Hyperdrive bindings for mixed caching
name = "my-api"
main = "src/index.ts"
compatibility_date = "2024-09-23"
compatibility_flags = ["nodejs_compat"]
# Cached binding — for read-heavy, staleness-tolerant queries
[[hyperdrive]]
binding = "HYPERDRIVE_CACHED"
id = "your-cached-config-id"
# Direct binding — for transactional or consistency-sensitive queries
[[hyperdrive]]
binding = "HYPERDRIVE_DIRECT"
id = "your-direct-config-id"
# Create them:
# npx wrangler hyperdrive create my-postgres-cached \
# --connection-string="postgresql://user:pass@db.example.com:5432/mydb"
# npx wrangler hyperdrive update my-postgres-cached \
# --caching-disabled=false --max-age=60 --stale-while-revalidate=15
#
# npx wrangler hyperdrive create my-postgres-direct \
# --connection-string="postgresql://user:pass@db.example.com:5432/mydb"
# npx wrangler hyperdrive update my-postgres-direct \
# --caching-disabled=true // Using dual Hyperdrive bindings in your Worker
import postgres from "postgres";
import { drizzle } from "drizzle-orm/postgres-js";
interface Env {
HYPERDRIVE_CACHED: Hyperdrive;
HYPERDRIVE_DIRECT: Hyperdrive;
}
function createCachedDb(env: Env) {
const client = postgres(env.HYPERDRIVE_CACHED.connectionString, {
fetch_types: false,
prepare: false,
});
return drizzle(client);
}
function createDirectDb(env: Env) {
const client = postgres(env.HYPERDRIVE_DIRECT.connectionString, {
fetch_types: false,
prepare: false,
});
return drizzle(client);
}
export default {
async fetch(request: Request, env: Env) {
const url = new URL(request.url);
if (url.pathname === "/api/products") {
// Product listing — cacheable, tolerates 60s staleness
const db = createCachedDb(env);
const products = await db.select().from(productsTable).limit(50);
return Response.json(products);
}
if (url.pathname === "/api/cart") {
// Shopping cart — must be consistent, no caching
const db = createDirectDb(env);
const cart = await db
.select()
.from(cartItemsTable)
.where(eq(cartItemsTable.userId, userId));
return Response.json(cart);
}
},
}; // Smart caching strategies for different query patterns
// Pattern 1: Reference data — long cache, no revalidation anxiety
// Product categories, country lists, feature flags
// max-age: 300 (5 minutes), stale-while-revalidate: 60
//
// These change so rarely that 5 minutes of staleness is invisible.
// A deploy that updates reference data can bust the cache via
// a Hyperdrive purge API call.
// Pattern 2: User-facing lists — moderate cache, fast revalidation
// Product listings, search results, feed items
// max-age: 30, stale-while-revalidate: 15
//
// Users tolerate 30-second-old search results. The 15-second
// stale window means revalidation happens in the background.
// Total maximum staleness: 45 seconds.
// Pattern 3: User-specific data — no cache
// Profile info, order history, account settings
// Disable caching for these queries entirely.
//
// Hyperdrive caches by exact SQL + parameters. A query like
// SELECT * FROM users WHERE id = $1 with parameter 42
// is a different cache key than the same query with parameter 43.
// But user-specific data has write-after-read consistency
// requirements that edge caching cannot satisfy.
// To disable caching per-query (not globally):
// There is no per-query cache control in Hyperdrive.
// Caching is all-or-nothing per Hyperdrive configuration.
// If you need mixed caching, create TWO Hyperdrive configs:
// - "my-postgres-cached" with caching enabled
// - "my-postgres-direct" with caching disabled
// Route queries to the appropriate binding based on intent. This pattern resolves the consistency objection entirely. Your product listing endpoint hits the cached binding and returns in 2ms. Your shopping cart endpoint hits the direct binding and sees immediate consistency. Both are routed through Hyperdrive's connection pool — the only difference is whether the edge cache is consulted.
The cost is two Hyperdrive configurations instead of one, which means two connection pools at each edge location. For most applications this is negligible. For applications with very high connection counts at the origin, you may need to account for the additional pool consuming backend connections. A reasonable starting point: each Hyperdrive configuration maintains 5-10 persistent connections per edge POP, and only active POPs maintain connections.
I should note an honest limitation. Hyperdrive's cache is per-colo. A query cached at the Tokyo POP is not available at the Singapore POP. This is by design — shipping cache entries across the globe would defeat the purpose of edge caching — but it means low-traffic regions will see lower cache hit rates. For a globally distributed application with sufficient traffic, this is not a problem. For a niche application with three users in Auckland, the cache will be cold more often than warm.
Local development: when Hyperdrive is not there
A practical matter that trips up every team during their first week with Hyperdrive: your local development environment does not have a Hyperdrive binding. The env.HYPERDRIVE object does not exist when you run wrangler dev without the --remote flag.
// Local development without Hyperdrive
// wrangler.toml — development overrides
// [env.dev]
// [env.dev.vars]
// DATABASE_URL = "postgresql://postgres:postgres@localhost:5432/mydb"
// In your Worker code, handle both paths:
function getClient(env: Env) {
// In dev, HYPERDRIVE binding doesn't exist.
// Fall back to a direct connection string.
const connectionString = env.HYPERDRIVE?.connectionString
?? env.DATABASE_URL;
if (!connectionString) {
throw new Error("No database connection configured");
}
return postgres(connectionString, {
fetch_types: false,
prepare: false,
// In dev, you might WANT fetch_types for debugging
// custom types. Toggle as needed.
});
}
// Alternatively, use wrangler's local Hyperdrive emulation:
// npx wrangler dev --local
//
// This spins up a local proxy that mimics Hyperdrive behavior
// but connects to your local PostgreSQL directly.
// Available since wrangler 3.18.0. The wrangler dev --local command, available since wrangler 3.18.0, provides a local Hyperdrive emulator that mimics the binding interface while connecting directly to your local PostgreSQL. This is the cleanest solution — your application code uses the same env.HYPERDRIVE.connectionString path in both environments, and the only difference is where the connection routes.
If you cannot use the local emulator (older wrangler version, custom dev tooling, or you simply prefer direct connections during development), the fallback pattern above works reliably. The ?.connectionString optional chain handles the absent binding gracefully.
One detail that catches teams: fetch_types: false is still the correct setting in local development for consistency with production. If you enable fetch_types locally and disable it in production, you may encounter type serialization differences between the two environments. Particularly with jsonb columns, where the catalog-based deserializer and the built-in deserializer can produce subtly different JavaScript object shapes in edge cases. Keep the configuration identical across environments.
Where Hyperdrive does not help: an honest accounting
A waiter who overstates his case is no waiter at all. Hyperdrive is a genuinely excellent piece of infrastructure, but it is not a universal solution, and I would be doing you a disservice to pretend otherwise.
Same-region deployments. If your Worker and your database are in the same region — or more precisely, if Cloudflare's nearest POP to your Worker is in the same region as your database — Hyperdrive's latency benefit is negligible. You saw it in the benchmarks: Virginia-to-Virginia showed 4.2ms direct vs 4.0ms Hyperdrive. Connection pooling still helps with connection setup overhead, but the per-query latency improvement is noise. If all your users are in one region and your database is in that region, Hyperdrive is solving a problem you do not have.
Write-heavy workloads. Hyperdrive optimizes reads far more effectively than writes. Write queries cannot be cached, must go to the origin regardless, and often involve transactions that pin a connection. A write-heavy Worker — think event ingestion, log processing, bulk imports — will see connection pooling benefits but none of the caching benefits that make Hyperdrive's read performance so impressive.
Queries that are slow at the origin. This is the most important limitation to internalize. If your query takes 200ms to execute at the database — because it performs a sequential scan on a 10-million-row table, or joins five tables without proper indexes, or aggregates a month of data without a materialized view — Hyperdrive will dutifully deliver that 200ms execution time from the nearest edge location. The transport is faster. The computation is identical. Hyperdrive reduces network latency; it does not reduce query execution time.
Connection limits at the origin. Each Hyperdrive POP maintains its own pool of connections to your database. If your database has a max_connections of 100 and Hyperdrive is maintaining connections from 20 active POPs, the connection math can get tight. Cloudflare manages this reasonably well — inactive POPs release connections, and the pool sizes are modest — but if you are already near your connection limit, adding Hyperdrive's pools may push you over. Check your current pg_stat_activity before deploying.
Exotic wire protocol features. Hyperdrive's connection pooler, like all connection poolers, has limits on what it can transparently proxy. LISTEN/NOTIFY does not work through Hyperdrive — these are session-level features that require a persistent, dedicated connection. COPY works for small payloads but may timeout for large bulk transfers. Custom authentication methods beyond password and SCRAM-SHA-256 may not be supported. If your application depends on these features, test them explicitly before committing to Hyperdrive.
Where does query optimization fit in?
Hyperdrive solves the connection latency problem with considerable elegance. Your Worker connects to a nearby edge pool instead of a distant database origin. Roundtrip time drops from 160ms to 15ms. The query arrives at PostgreSQL faster.
But it arrives unchanged.
Hyperdrive does not inspect your SQL. It does not know whether your WHERE clause would benefit from an index that does not exist. It does not notice that your subquery could be flattened into a join. It does not observe that the same expensive aggregation runs 10,000 times per hour and should be materialized. Hyperdrive optimizes the transport. The query itself executes at whatever speed PostgreSQL manages with whatever indexes and plan it has.
This is where Gold Lapel enters the picture, and the two tools address genuinely different bottlenecks. Hyperdrive reduces the time between your Worker and the database. Gold Lapel reduces the time the database spends executing the query once it arrives. They are complementary in the most literal sense — one handles network latency, the other handles compute latency.
// The full stack: Hyperdrive for edge pooling, Gold Lapel for query optimization
// wrangler.toml
// [[hyperdrive]]
// binding = "HYPERDRIVE"
// id = "your-config-id"
//
// Point Hyperdrive at Gold Lapel instead of raw Postgres:
// npx wrangler hyperdrive create my-gl-postgres \
// --connection-string="postgresql://user:pass@gl-proxy.internal:5433/mydb"
//
// Connection path:
// Worker → Hyperdrive (edge pool) → Gold Lapel (query optimization) → Postgres
//
// Hyperdrive eliminates:
// - TLS handshake latency from the edge
// - Connection creation overhead
// - Unnecessary roundtrips for repeated reads (caching)
//
// Gold Lapel eliminates:
// - Sequential scans that should use indexes (auto-indexing)
// - Redundant subqueries and inefficient joins (query rewriting)
// - Repeated expensive computations (materialized view management)
// - Manual EXPLAIN ANALYZE debugging (continuous optimization)
//
// They don't overlap. They don't conflict. They stack.
import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";
export function createDb(env: Env) {
// Same code as before — the connection string routes
// through Hyperdrive → Gold Lapel → Postgres.
// No configuration changes in your application code.
const client = postgres(env.HYPERDRIVE.connectionString, {
fetch_types: false,
});
return drizzle(client);
} The deployment is straightforward: point your Hyperdrive configuration at Gold Lapel's address instead of your raw PostgreSQL instance. Hyperdrive manages edge connection pools to Gold Lapel. Gold Lapel sits in front of PostgreSQL at the origin, continuously analyzing query patterns, creating indexes that the workload needs, rewriting inefficient SQL, and managing materialized views for expensive repeated computations.
In the benchmarks above, the 11-15ms Hyperdrive latency from distant regions includes query execution time at the origin. If your query takes 50ms to execute because it is performing a sequential scan on a table that should have an index, Hyperdrive faithfully delivers that 50ms execution plus ~12ms transport. Gold Lapel's job is to turn that 50ms execution into a 2ms index scan. The combined result: 14ms total instead of 212ms direct, with both transport and compute optimized.
Neither tool makes the other less useful. A connection that arrives quickly at a slow database is still slow. A fast database accessed over a slow connection is still slow for distant users. You need both paths optimized, and they do not step on each other's work.
I would be remiss not to mention that Gold Lapel is our product — the service this household represents. I mention it here because the architectural fit is genuine, not because I wish to transform a technical guide into a sales pitch. Every technique in this article works without Gold Lapel. The Hyperdrive configuration, the fetch_types optimization, the prepared statement fix, the dual-binding caching strategy — all of it is independently useful. If you optimize your queries through other means — careful manual indexing, periodic EXPLAIN ANALYZE reviews, a DBA with strong opinions — the transport-layer improvements from Hyperdrive still apply in full.
Practical recommendations
Having tested these configurations across several production Workers, a few patterns have crystallized into firm recommendations.
Use postgres.js over node-postgres on Workers. The 30KB vs 120KB bundle difference directly affects cold start times, and the fetch_types: false optimization is a free latency win. Unless you have a specific dependency on pg's API, choose postgres.js.
Always set fetch_types: false with postgres.js on Hyperdrive. The extra roundtrip for type discovery adds 10-15ms per first query in a Worker invocation. Workers are ephemeral — every invocation is potentially a "first query." The performance impact compounds.
Always set prepare: false with Hyperdrive. The intermittent "prepared statement does not exist" error is subtle, load-dependent, and catastrophic when it hits. The 0.5-2ms cost per query is insurance you can afford.
Set max: 1 on any local pool configuration. Hyperdrive is the pool. Your Worker's in-process pool should be minimal. Multiple local connections compete for the same Hyperdrive-managed backends without improving throughput.
Reuse clients at the module level. Do not create a new postgres() client on every request. Create one when the isolate loads and reuse it. The connection to Hyperdrive is cheap but not free.
Enable Hyperdrive caching for read-heavy endpoints. The default is caching disabled, which is conservative and safe. But if your API serves data that tolerates 60-second staleness — and most data does — caching turns a 12ms query into a 2ms cache hit. The improvement is too large to leave on the table.
Use dual Hyperdrive bindings for mixed workloads. If you need caching for reads and consistency for writes, create two configurations. The operational overhead is minimal; the architectural clarity is significant.
Benchmark from your actual user regions, not just locally. Hyperdrive's value is proportional to the distance between your users and your database. If all your traffic comes from the same region as your database, Hyperdrive provides connection reuse but minimal latency improvement. If your traffic is global, Hyperdrive is transformative. Know your geography before you optimize for it.
And if your queries are slow even after the transport is fast, that is a different problem — one that involves how connections are pooled, how proxies route and rewrite, and ultimately how the database itself is tuned. Hyperdrive gives your queries a fast lane to the origin. Make sure the origin is ready for them when they arrive.
The complete configuration, for those who prefer to see it all at once
I have covered a great deal of ground — drivers, optimizations, gotchas, caching strategies, and benchmarks. If you prefer a concise reference to the full narrative, here is what a production-ready Hyperdrive + Drizzle configuration looks like, incorporating every recommendation from this guide.
In your wrangler.toml: use nodejs_compat, define your Hyperdrive bindings (one cached, one direct if you need mixed caching), and set a recent compatibility date.
In your database module: use postgres.js with fetch_types: false, prepare: false, and max: 1. Create the client at module scope and reuse it across requests. Wrap it with Drizzle (or Kysely) for your query builder.
In your Worker handler: use the module-scoped database instance directly. Use db.transaction() for transactional operations. Route cacheable queries through the cached binding, consistency-sensitive queries through the direct binding.
In your Hyperdrive configuration: enable caching with a max-age appropriate to your data freshness requirements and a stale-while-revalidate window to smooth over cache transitions.
In your CI/CD: run migrations directly against the database origin, not through Hyperdrive. Test with wrangler dev --local using the Hyperdrive emulator for consistent behavior between environments.
That is the complete picture. The individual pieces are straightforward; the value is in assembling them correctly from the start, rather than discovering each gotcha in production. If I have saved you even one 2 AM debugging session over prepared statements or type discovery roundtrips, then this guide has earned its keep.
In infrastructure, boring is the highest compliment available. A well-configured Hyperdrive deployment is profoundly boring. Your users in Tokyo get their data in 15 milliseconds instead of 200. Your users in Amsterdam get theirs in 12 instead of 90. Nobody notices, because fast things are invisible. That is the goal. That is always the goal.