cloudflare / cloudflare/cloudflare-docs

Hyperdrive Postgres.js example: clarify per-request round trips (describe-first, fetch_types) and when node-postgres is the better fit

Open
#33,104 0 comments 0 reactions 6 assignees Claimed by @vy-ton View on GitHub
product:hyperdrive
Dominant language
MDX
Stars
5.2k
Forks
16.7k
Avg merge
2d 6h
Merged PRs (30d)
337

Description

### Existing documentation URL(s)

- https://developers.cloudflare.com/hyperdrive/examples/connect-to-postgres/postgres-drivers-and-libraries/postgres-js/
- https://developers.cloudflare.com/hyperdrive/examples/connect-to-postgres/postgres-drivers-and-libraries/node-postgres/
- Source partials: `src/content/partials/hyperdrive/use-postgres-js-to-make-query.mdx`, `src/content/partials/hyperdrive/node-postgres-recommendation.mdx`

### What changes are you suggesting?

The Postgres.js example works, and this is not a claim that Postgres.js is broken or unsupported. The two inline comments in the example, however, describe round trips only in terms of `fetch_types` and Hyperdrive's prepared-statement caching. There is a third, driver-side cost that is independent of caching and that the Workers execution model makes unavoidable: Postgres.js's **describe-first** behaviour for parameterized queries. Its per-connection statement cache would normally absorb that cost after the first execution, but a Worker creates a new client per request (as the docs recommend, and as the runtime requires), so the cache never warms. The result is that **even with the exact options in the example (`fetch_types: false`, `prepare: true`), a fresh client's first parameterized query is two database round trips, whereas node-postgres does it in one.** I think the page should say so, and the recommendation note should say when `pg` is the better choice rather than only that it has "the best compatibility with Hyperdrive's caching".

#### What the driver does (postgres 3.4.9, `src/connection.js`)

1. **Type fetch on connect.** With `fetch_types: true` (the default) the connection's first `ReadyForQuery` triggers `fetchArrayTypes()`, which runs `select b.oid, b.typarray from pg_catalog.pg_type …` and awaits it before the first user query. One extra round trip per fresh connection. The docs already cover this one.
2. **Describe-first for parameterized queries.** `build()` sets `q.describeFirst = q.onlyDescribe || (parameters.length && !q.prepared)` (line 238). When true, `toBuffer()` sends only `Parse + Describe + Flush` (line 192), and `Bind + Execute + Sync` is written from the `ParameterDescription` handler after the server has answered (line 633). So the first execution of a given parameterized statement on a given socket is two round trips. `q.prepared` is `q.signature in statements`, and `statements = {}` is a closure variable of the `Connection` (line 86), reset whenever the socket reconnects (line 367): the cache is per socket, not per process.
3. **`prepare: false`.** Nothing is ever cached, so every parameterized query is two round trips. The example's comment that `prepare: false` "will require additional round-trips" is therefore true for two independent reasons, Hyperdrive's cache and the driver's own protocol flow, and only the first is mentioned.

#### Why Workers makes this a per-request cost

"TCP sockets cannot be created in global scope and shared across requests" (Workers TCP sockets docs), and both example pages tell the reader to create a new client per request. Each request is therefore a fresh socket with an empty `statements` map, and every request pays the describe-first round trip on its first parameterized query, and again for each distinct parameterized statement it runs. Hyperdrive's pooling removes the connection-setup cost the docs describe, but a `Describe` is a protocol message inside the session; in our measurements its answer cost a full origin round trip.

#### node-postgres for comparison (pg 8.17.2, `lib/query.js`)

`Query.prepare()` writes `Parse`, `Bind`, `Describe('P')`, `Execute` and `Sync` back to back through `connection._send()` without waiting for any reply, so a parameterized query is one round trip on a fresh client. `pg` also fetches nothing on connect: `pg-types` ships parsers for the common array types (`_int4` 1007, `_text` 1009, `uuid[]` 2951, and so on), which is why it has no `fetch_types` equivalent.

#### Minimal reproduction (no database needed)

A ~100-line mock Postgres server that speaks just enough of the extended protocol to answer `Parse`, `Describe`, `Bind`, `Execute` and `Sync`, delays every reply by 40 ms to simulate RTT, and logs which client messages arrived before it had to answer. Every logged "flight" is one round trip. Each scenario is a brand-new client, the per-request shape, running the same parameterized read twice. Script and log below; I can attach the mock server as a gist or PR fixture.

```js
// run.mjs (excerpt)
const sql = postgres(url, { max: 1, fetch_types: false }); // the documented example's options
await sql`select 1 where 1 = ${1}`;
await sql`select 1 where 1 = ${1}`;

const c = new pg.Client({ connectionString: url });
await c.connect();
await c.query("select 1 where 1 = $1", [1]);
await c.query("select 1 where 1 = $1", [1]);
```

```
## postgres.js defaults (fetch_types: true, prepare: true)
flight 1: [Startup]
flight 2: [Parse, Describe, Bind, Execute, Sync] Parse params=0 sql=select b.oid, b.typarray from pg_catalog.pg_type …
flight 3: [Parse, Describe, Flush] Parse params=1 sql=select 1 where 1 = $1
flight 4: [Bind, Execute, Sync]
-- first statement resolved -- (3 round trips after connect)
flight 5: [Bind, Execute, Sync]
-- second statement resolved -- (1 round trip: statement cache hit)

## postgres.js fetch_types: false, prepare: true (the documented example)
flight 1: [Startup]
flight 2: [Parse, Describe, Flush]
flight 3: [Bind, Execute, Sync]
-- first statement resolved -- (2 round trips after connect)
flight 4: [Bind, Execute, Sync]
-- second statement resolved -- (1 round trip)

## postgres.js fetch_types: false, prepare: false
flight 1: [Startup]
flight 2: [Parse, Describe, Flush]
flight 3: [Bind, Execute, Sync]
-- first statement resolved -- (2 round trips)
flight 4: [Parse, Describe, Flush]
flight 5: [Bind, Execute, Sync]
-- second statement resolved -- (2 round trips, every time)

## postgres.js fetch_types: false, no parameters
flight 1: [Startup]
flight 2: [Parse, Describe, Bind, Execute, Sync]
-- first statement resolved -- (1 round trip; this is why a `select 1` probe never shows the cost)

## pg (node-postgres) Client, parameterized
flight 1: [Startup]
flight 2: [Parse, Bind, Describe, Execute, Sync]
-- first statement resolved -- (1 round trip)
flight 3: [Parse, Bind, Describe, Execute, Sync]
-- second statement resolved -- (1 round trip)
```

#### Production measurement through Hyperdrive

Worker → Hyperdrive → PostgreSQL on PlanetScale, one client per request, timing lines around each statement (2026-08-26):

- `fetch_types: true`: a fresh client's first statement 159–203 ms; `fetch_types: false`: 80–102 ms. That is the round trip the docs already describe.
- With `fetch_types: false`, a fresh client's first **parameterized** statement still took 160–164 ms, while a second statement on the same client took 78–80 ms. The difference is one origin round trip, and it is the describe-first flight above. `select 1`-style probes did not show it, which is how it went unnoticed for a while.

So on this path a request that runs one parameterized query has a floor of two origin round trips with Postgres.js and one with `pg`. We moved to `pg` for that reason; the Postgres.js API is nicer to write and the trade-off is a legitimate one either way, which is why I think the page should state it rather than leave the reader to measure it.

#### Suggested wording

Under the Postgres.js example, something like:

> Postgres.js runs a parameterized query as two round trips the first time it executes on a connection (`Parse` and `Describe`, then `Bind` and `Execute`) and caches the statement per connection so later executions take one. Because a Worker creates a new client per request, that cache starts empty on every request: each request pays one extra round trip for its first parameterized query, and again for each distinct query it runs, on top of the `fetch_types` round trip when that is enabled. With `prepare: false` every parameterized query takes two round trips. node-postgres sends `Parse`, `Bind`, `Describe`, `Execute` and `Sync` together, so a query is one round trip on a fresh client, and it fetches no type information on connect. If your request path runs a small number of queries and latency matters, `pg` avoids this cost. If you prefer the Postgres.js API, set `fetch_types: false`, keep `prepare: true`, and budget roughly one extra origin round trip per request.

And in the shared recommendation note, alongside "best compatibility with Hyperdrive's caching", one clause such as "and the fewest round trips per query on a per-request client".

### Additional information

- Postgres.js: 3.4.9, `src/connection.js` lines 84–88, 191–196, 238–241, 555–566, 626–634, 768–779. The `cf/src` build the Workers export resolves to is a transpiled copy of the same file, so the flow is identical there.
- node-postgres: 8.17.2, `lib/query.js` `requiresPreparation()` and `prepare()`, `lib/connection.js` `_send()`.
- Neither driver is misbehaving; both follow their documented design. The gap is only that the Workers per-request client model turns Postgres.js's per-connection warm-up into a per-request cost, and the page currently reads as if `fetch_types: false` plus `prepare: true` removes the extra round trips.

Contributor guide

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.