Connect and query
Open and close a driver per request
Let Hyperdrive manage the underlying pool while Worker code creates a request-scoped driver connection and closes it reliably.
On a traditional server you create one database client at startup and share it forever. In a Worker that habit breaks things. Workers don’t let ordinary I/O objects, and a database client is one, cross from one request to another.
The rule: create the driver client inside the request, connect through Hyperdrive, and close it in finally.
import postgres from 'postgres'
export default {
async fetch(request, env, ctx) {
const sql = postgres(env.HYPERDRIVE.connectionString)
try {
const userId = await authenticate(request)
const rows = await sql`
select id, title from notes
where user_id = ${userId}
order by created_at desc limit 20
`
return Response.json(rows)
} finally {
ctx.waitUntil(sql.end())
}
},
}
env.HYPERDRIVE.connectionString is a credential Cloudflare generates for the pool. The origin database password never appears in your source. The driver needs Node.js APIs, so compatibility_flags must include nodejs_compat.
ctx.waitUntil(sql.end()) closes the client after the response is sent, without delaying it. The finally guarantees this runs on the error path too. A client you forget to close holds its connection until timeout. Under load those leaks add up.
Why per-request clients are cheap here
Creating a client per request sounds wasteful. It isn’t, because Hyperdrive pools the connections to the origin. Your new client does a fast handshake with a nearby Hyperdrive endpoint and borrows a warm connection. The expensive remote handshake happened once, near the database. That was the whole point of the previous lesson.
The error you’ll hit if you ignore this
Hoist the client to module scope and requests start dying with this:
Error: Cannot perform I/O on behalf of a different request.
I/O objects (such as streams, request/response bodies, and others)
created in the context of one request handler cannot be accessed
from a different request's handler.
The runtime sees a connection created during one request being reused by another and refuses. The fix is always the same. Move client creation back inside fetch.
Everything you know about database APIs still applies. Validate request input. Bind SQL values, which the tagged template above does for userId automatically. Return safe errors instead of leaking driver messages to clients.
Try both paths: run one parameterized read and confirm rows come back, then query a table that doesn’t exist and confirm the client still closes. Add a console.log inside the finally while testing if you want proof it runs on both.
Lesson completed