Skip to content

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

Description

@newconfinedmind

Existing documentation URL(s)

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.

// 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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

product:hyperdriveHyperdrive: https://developers.cloudflare.com/hyperdrive/

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions