Hyperdrive for Connecting Cloudflare Workers to a Vector Database
10 min read · updated August 11, 2026
Postgres with pgvector is a perfectly good vector database, right up until the client is a Worker. Workers are many short-lived isolates in many locations, and Postgres is a server that allocates a process per connection. Hyperdrive is the thing in the middle.
Why a Worker cannot just open a connection
Two mismatches, both structural. First, cost per connection: a Postgres connection costs a TCP handshake, a TLS handshake and an authentication round trip before a single query runs, and against a distant database that is several round trips of pure setup on a request that needed one query. Second, count: Postgres has a bounded max_connections, and an edge platform that scales isolates with traffic has no natural bound at all. The traditional fix — a long-lived pool in your application process — assumes a long-lived application process, which is the thing a Worker is not.
Hyperdrive holds the pooled connections itself, close to your database, and gives the Worker a connection string that points at it. Your driver code does not change.
Creating the configuration and binding
- Create the configuration with your real database connection string. The credentials go to Cloudflare here and not into your Worker.
- Add the binding to your Wrangler configuration, with the id printed by the create command.
- Set
compatibility_flagsto includenodejs_compatand acompatibility_dateof 23 September 2024 or later, which Cloudflare documents as the requirement. - Install a supported driver. Cloudflare’s guide uses node-postgres (
pg) at version 8.13.0 or later.
npx wrangler hyperdrive create vectors-prod \ --connection-string="postgres://user:[email protected]:5432/vectors" npm i pg@^8.13.0
// wrangler.jsonc
{
"name": "vector-search",
"main": "src/index.ts",
"compatibility_date": "2026-08-11",
"compatibility_flags": ["nodejs_compat"],
"hyperdrive": [
{ "binding": "HYPERDRIVE", "id": "<id printed by wrangler hyperdrive create>" }
]
}Note what is not in that file: the database password. It went to Cloudflare with the create command and lives in the Hyperdrive configuration, so your repository holds an opaque id. That is a real security improvement over a connection string in a Worker secret, and it is also the reason rotating the database password means running wrangler hyperdrive update rather than redeploying.
The Worker: a pgvector similarity search
The binding exposes connectionString, which you hand to the driver as if it were the database itself.
import { Client } from "pg";
export interface Env {
HYPERDRIVE: Hyperdrive;
}
export default {
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
const { embedding, tenant, k } = await request.json<{
embedding: number[];
tenant: string;
k: number;
}>();
const client = new Client({ connectionString: env.HYPERDRIVE.connectionString });
await client.connect();
try {
// pgvector takes a literal of the form '[0.1,0.2,...]'.
const vectorLiteral = `[${embedding.join(",")}]`;
const result = await client.query(
`SELECT id, title, 1 - (embedding <=> $1::vector) AS similarity
FROM documents
WHERE tenant = $2
ORDER BY embedding <=> $1::vector
LIMIT $3`,
[vectorLiteral, tenant, Math.min(k ?? 10, 50)],
);
return Response.json(result.rows);
} finally {
// Close after the response is sent, not before it.
ctx.waitUntil(client.end());
}
},
};Three details are load-bearing. The <=> operator is pgvector’s cosine distance, so 1 - distance is a similarity in the direction people expect; if your index was built for L2 or inner product, the operator and the arithmetic both change. ORDER BY on the same expression is what lets the index be used — an ORDER BY similarity DESC on the computed alias will not be. And the LIMIT is clamped, because a client-supplied k of 100,000 is a denial-of-service against your own database.
Query caching, and why it needs thought here
Hyperdrive caches read queries by default. Cloudflare documents the default max_age as 60 seconds and stale_while_revalidate as 15 seconds, and documents that non-mutating queries are cacheable while writes and queries using volatile functions such as NOW() and RANDOM() are not.
For a vector search this cuts both ways, and it is the part of the setup most likely to surprise you. A similarity search is a read, so it is cacheable — and its parameters are a 1,536-dimension float vector, which is almost never byte-identical between two requests. Real user queries will therefore miss, giving you the cache’s cost and none of its benefit; while the queries that do repeat — health checks, a demo query, a retry of the same request — will hit, and will keep returning the pre-ingest result for up to 75 seconds after you insert new documents.
That staleness is the failure mode to watch for: “I inserted a document and search does not find it”, resolving itself a minute later. The clean answer is two Hyperdrive configurations against the same database — one with caching for the genuinely repeated reads (configuration rows, prompt templates), one created with --caching-disabled for the search path — bound separately:
npx wrangler hyperdrive create vectors-nocache \ --connection-string="postgres://user:[email protected]:5432/vectors" \ --caching-disabled
"hyperdrive": [
{ "binding": "HYPERDRIVE", "id": "<cached config id>" },
{ "binding": "HYPERDRIVE_FRESH", "id": "<caching-disabled config id>" }
]Transactions, recall settings and pinning
Pooling works because connections are shared between requests. A transaction breaks that sharing by definition: for as long as it is open, one database backend belongs to you and to nobody else. That is fine when a transaction is three statements and two milliseconds, and it is the single most damaging thing you can do here when it is not.
The specific mistake to avoid is holding a transaction open across an await on a model call:
// WRONG: pins one Postgres backend for the whole inference.
await client.query("BEGIN");
const hits = await client.query(searchSql, [vectorLiteral, tenant]);
const answer = await callModel(hits); // 8 seconds of nothing
await client.query("INSERT INTO answers ...", [answer]);
await client.query("COMMIT");Eight seconds of inference is eight seconds during which a Postgres process is doing nothing and is unavailable to anyone else. A hundred concurrent requests in that shape will exhaust max_connections no matter what is pooling in front, because the pool cannot reuse what you are holding. Hyperdrive lowers connection setup cost and connection churn; it does not raise your database’s ceiling. Close the transaction before the model call and open a second one afterwards.
This collides with pgvector tuning in a way worth planning for. pgvector’s documentation sets search-quality parameters as session variables: hnsw.ef_search, documented with a default of 40, controls the size of the dynamic candidate list for an HNSW index, and ivfflat.probes controls how many lists are scanned for an IVFFlat index. The documented way to set either for one query is SET LOCAL inside a transaction — which means the correct version of a tuned search is a transaction, just a very short one:
await client.query("BEGIN");
try {
await client.query("SET LOCAL hnsw.ef_search = 100");
const result = await client.query(searchSql, [vectorLiteral, tenant, limit]);
await client.query("COMMIT");
return result.rows; // model call happens AFTER this
} catch (err) {
await client.query("ROLLBACK");
throw err;
}hnsw.ef_search and its default of 40 are from the pgvector project’s own documentation; check it for your installed version before tuning. pgvector, READMERaising ef_search improves recall and costs latency, and the right value is a property of your data rather than something to copy from a page. pgvector documents the way to measure it: run the same query with the index disabled to get the exact answer, and compare the two result sets. That is the check to run once against a sample of real queries, not a number to guess at — an approximate index quietly returning the wrong neighbours is invisible from the application, which is why it is worth measuring before you ship.
The pitfalls worth knowing first
- Closing the client matters. Hyperdrive pools, but your driver still holds a client object per invocation. Ending it inside
ctx.waitUntilkeeps the teardown off the response path while still releasing it. - Interpolating the vector, parameterising the rest. The example above passes the vector as a bound parameter cast with
$1::vector. Building the whole SQL string with the embedding inlined works and is a habit that will eventually inline something that came from a user. - Pair it with placement. A Worker in Sydney querying a database in Frankfurt still crosses the world; Hyperdrive removes the setup round trips, not the distance. If the same request also calls a model API in the same region as the database, Smart Placement is the other half of the fix.
- Consider whether you need Postgres at all. If the only reason the database exists is vector search, Vectorize is a binding with no connection management to think about. Postgres wins when the vectors live next to relational data you need to filter and join on in the same query — which is a good reason, and the reason this page exists.