Named Prepared Statements Come to Hyperdrive

Hyperdrive, Cloudflare's globally distributed SQL connection pooler and cache, has added support for Postgres protocol-level named prepared statements across pooled connections. Named prepared statements let Postgres cache query execution plans for reuse, cutting parsing overhead and network round-trips. This also matters because many popular Postgres drivers use them by default—so their absence has been a real footgun for developers. The update brings better performance and smoother development workflows without requiring application changes.

Other poolers, like PgBouncer, have had this feature since version 1.21 in late 2023. But Hyperdrive's dual role as a cache presented unique implementation challenges worth unpacking.

The Postgres Extended Query Protocol

To understand the difficulty, you need some grounding in the Postgres Message Protocol—specifically the extended query flow, which is more complex than a simple query but far more widely used. A typical Hyperdrive query might look like this in code:

import postgres from "postgres";

// with Hyperdrive, we don't have to disable prepared statements anymore!
// const sql = postgres(env.HYPERDRIVE.connectionString, {prepare: false});

// make a connection, with the default postgres.js settings (prepare is set to true)
const sql = postgres(env.HYPERDRIVE.connectionString);

// This sends the query, and while it looks like a single action it contains several 
// messages implied within it
let [{ a, b, c, id }] = await sql`SELECT a, b, c, id FROM hyper_test WHERE id = ${target_id}`;

Behind the scenes, a client preparing a statement begins with a Parse message containing the query string, parameter count, and the statement name. If the name is empty, the statement goes into Postgres's "unnamed" slot and gets overwritten on every new Parse. Most drivers keep the full message sequence for unnamed statements together since they're frequently replaced.

A named statement, however, persists for the rest of the session unless explicitly removed via DEALLOCATE. That persistence is a big win—re-parsing costs bytes on the wire plus CPU cycles, so reusing a statement is a meaningful optimization.

The remainder of a basic extended query consists of:

  • A Bind message supplying concrete values for the statement's parameters (if any).
  • An Execute message triggering actual data processing and retrieval.
  • A Sync message closing the implicit transaction, returning results, and giving error handling a synchronization point.

The protocol grows more intricate with named portals, error responses, and Describe messages. Many drivers use Describe to fetch type information for deserializing results into structs or classes. In those cases, a Parse-Describe-Flush/Sync sequence generates a second query and a second kind of response, complicating the bookkeeping around named statements. Handling that complexity is mandatory for a pooler to support prepared statements gracefully.

From a message perspective, the basic query maps to:

BLOG-2446 Embedded Image - BZcRMA

Postgres's documentation covers the full extended query flow and message formats in detail.

Why Buffering Is Hard

Unlike most connection poolers, Hyperdrive is also a cache. A Parse alone is insufficient as a cache key—parameter values in the Bind can change the expected results, and a following Describe or Execute demands drastically different responses. Hyperdrive can't just forward each message to the origin database; it has to buffer them in a message log to tell cache keys apart reliably. Receiving a Sync is a natural checkpoint for deciding whether there's enough information to answer. In most cases, Hyperdrive buffers until Sync, then determines whether to serve from cache or grab an origin connection.

Pooling vs. Session Scope

If the cache can't answer, Hyperdrive takes a connection from its pool. Hyperdrive runs in "transaction mode," meaning a pooled connection returns to the pool when the transaction ends—unlike "session mode," where a client keeps the connection until disconnecting. Session mode would pin one client to one database connection; with many small Workers spread across the globe, that exhausts the database connection budget quickly.

Prepared statements create tension here: they live at the session level, bound to a single connection. If a client prepares on connection A and is reassigned connection B for reuse, Postgres throws an error claiming the statement doesn't exist—forcing a retry with a new Parse and defeating the optimization. A pooler aims to be transparent, keeping clients and servers unaware of the multiplexing between them. When a client sends Parse, it expects the statement to be reusable later; the server must never receive Bind for a statement only defined in a different session. Maintaining that illusion is the core problem.

The Implementation

The solution relies on a handful of Rust standard data structures—a HashMap, an LruCache or two, and a VecDeque—plus logic to decide when to interject into the message flow.

When a named Parse arrives, Hyperdrive stores the full message in an in-memory HashMap on the per-client message-processing server. That persists for the connection's lifetime, so any later reference to the statement retrieves its complete definition.

After buffering all possible messages and hitting a Sync boundary, two questions decide the path:

  1. Does this sequence include a Parse or reuse an existing statement?
  2. Is it a cache hit or are we going to the origin?

Those combine into four cases—parse-with-cache-hit, parse-with-cache-miss, reuse-with-cache-hit, and reuse-with-cache-miss. (Error handling adds complexity but is outside this scope.)

Parse and Cache Hit

The simplest path. The incoming messages form the cache key, results are served to the client, and the Parse is still stored in the HashMap for future reuse. Nothing else needed.

BLOG-2446 Embedded Image - pcLJT1

Parse and Cache Miss

This path requires forwarding the message sequence to the origin over a randomly chosen pooled connection. That connection's session state diverges from its siblings, so each pooled connection keeps an LruCache tracking which statements it has prepared. Eviction triggers a DEALLOCATE to keep the bookkeeping exact.

BLOG-2446 Embedded Image - fVx8yW

Reuse and Cache Hit

Recognizing a Bind arriving without a preceding Parse is the trigger here. Hyperdrive retrieves the stored Parse to build the cache key and serves cached results—removing the ParseComplete from the cached response, since the client never issued a Parse in this exchange.

BLOG-2446 Embedded Image - NruyBZ

Reuse and Cache Miss

The hardest case: Hyperdrive may need to cover both directions. When a Bind arrives with parameters differing from anything cached, the flow goes—recognize the missing Parse, retrieve it from the HashMap, confirm the cache miss, and take a pooled connection. If that connection doesn't already have the statement prepared, Hyperdrive prepends the saved Parse to its message log to create a valid Parse-Bind-Execute-Sync sequence for the origin. The VecDeque makes those edits ergonomic, letting Hyperdrive splice messages in without rebuilding the full byte stream. The origin's response includes a ParseComplete that must be trimmed; sending it back would confuse a client that never sent a Parse. After the trim, the client receives exactly what it requested.

BLOG-2446 Embedded Image - 8z4sJd

Tradeoffs and Results

The working solution allows Hyperdrive to share database connections across arbitrary clients—no pinning, no custom client or server handling—while supporting prepared-statement reuse. The cost is occasional extra Parse injections when a reassigned client hits a connection lacking the statement, plus some memory overhead from the same statement prepared on multiple connections. Given what's saved in dropped round-trips and CPU time re-parsing queries, that's a fair trade.