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.

8 minute lesson

~~~

On a traditional server you create one database client at startup and share it forever. In a Worker that habit breaks things, because Workers do not preserve ordinary database client I/O safely across requests.

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, so 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, and 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 underlying origin connections. Your new client performs a fast handshake with a nearby Hyperdrive endpoint and borrows a warm connection. Request-scoped client objects do not recreate every remote handshake — that was the whole point of the previous lesson.

The failure you’ll hit if you ignore this: hoist the client to module scope and requests start dying with an error like 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 detects 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 (the tagged template above parameterizes userId automatically), and return safe errors instead of leaking driver messages to clients.

Verify both paths: run one parameterized read and confirm rows come back, then force one error — query a table that doesn’t exist — and confirm the client still closes. Log inside the finally while testing if you want proof it executes on both.

Lesson completed

Take this course offline

Get every free book and course as PDF and EPUB files.

Get the download library →