Databases 7 min read 1343 words

Postgres over QUIC: the bottleneck that isn't in the database

ES
Postgres over QUIC: the bottleneck that isn't in the database

Anyone who has taken Postgres to production under some load has been through the same thing: you put PgBouncer or PgCat in front, configure transaction pooling, cap the real server connections so it doesn’t run out of memory… and queries are still slow during peaks, with the database barely doing anything.

The article Postgres with QUIC puts the spotlight on a place we almost never look: the hop between the application and the pooler. And to fix it they try something I hadn’t seen before: speaking the Postgres protocol over QUIC instead of TCP.

The problem: one connection, one query

The Postgres protocol is synchronous at the connection level. When a client sends a query, that connection stays busy until the server replies with the result and ReadyForQuery. You can’t mix two independent queries from different threads on the same connection.

That’s not a problem when the application sits next to the database. It is when it doesn’t, and these days that’s quite common: serverless functions, edge workers, microservices in another availability zone or another region. The article lays out a very realistic scenario:

HopLatency
Application → pooler (cross-region)30 ms round trip
Pooler → Postgres (same network)under 2 ms
Executing an indexed query0.5 ms

The database answers the query in half a millisecond, but the client connection is blocked for 30 ms waiting for the bytes to travel there and back. And this is where the arithmetic comes in, which is the best part of the article. By Little’s Law, with a pool of 10 connections:

10 connections / 0.030 seconds = 333 queries per second, at most.

It doesn’t matter that the server has 64 cores at 3% usage or that the pooler has 100 warm connections waiting. If 500 requests arrive at once, 490 are left queued inside your application, and the last one waits a second and a half before it even hits the network. Your monitoring dashboard will say “the database takes 1,500 ms”, when Postgres ran it in 0.4 ms.

Why opening more connections isn’t enough

The obvious answer is to increase the pool size. The article goes through why that hits a wall:

  • File descriptors: each TCP connection is one, and with many pods you end up with the famous EMFILE: too many open files.
  • Kernel memory: every socket has its send and receive buffers. Thousands of idle connections eat hundreds of megabytes.
  • Handshakes: if a connection drops, rebuilding it costs the TCP handshake plus the TLS one. With 30 ms of latency, that’s 60-90 ms before sending a single line of SQL.
  • Head-of-line blocking: if a packet is lost, the whole TCP connection stalls until it’s retransmitted.

What QUIC brings

QUIC is the UDP-based protocol used by HTTP/3, and its key feature here is that it allows many independent streams inside a single connection. Opening a stream doesn’t create a kernel socket, doesn’t use a descriptor and needs no handshake: it’s an identifier inside an encrypted connection that already exists. And if a stream loses a packet, only that stream pauses.

With 50 QUIC connections and 40 streams on each, the numbers change completely:

2,000 streams / 0.030 seconds = 66,666 queries per second of theoretical capacity.

flowchart LR
    subgraph TCP["TCP pool"]
        A1["Query 1"] --> S1["Socket 1"]
        A2["Query 2"] --> S2["Socket 2"]
        A3["Query N"] --> S3["Socket N"]
    end
    subgraph QUIC["QUIC pool"]
        B1["Query 1 · stream"] --> Q1["One UDP connection"]
        B2["Query 2 · stream"] --> Q1
        B3["Query N · stream"] --> Q1
    end
    S1 & S2 & S3 --> P["PgCat · Postgres"]
    Q1 --> P

What I liked most is how clean the implementation is. They didn’t touch the Postgres protocol. In Rust, tokio-postgres has a connect_raw function that accepts anything implementing AsyncRead and AsyncWrite, and the streams from s2n-quic, AWS’s QUIC library, do. So you just open a stream and hand it over:

// Open a lightweight stream over an already established QUIC connection
let stream = connection.open_bidirectional_stream().await?;

// tokio-postgres uses it as if it were a regular socket
let (pg_client, pg_connection) = pg_config.connect_raw(stream, NoTls).await?;

tokio::spawn(async move {
    if let Err(e) = pg_connection.await {
        eprintln!("Postgres connection error: {}", e);
    }
});

let rows = pg_client
    .query("SELECT id, username FROM users WHERE id = $1", &[&user_id])
    .await?;

The NoTls isn’t an oversight: QUIC already encrypts everything with TLS 1.3, so adding Postgres TLS on top would mean encrypting twice. On the other end, they modified PgCat to also listen on UDP, accept the streams and feed them into its usual pooling logic.

The numbers

In their benchmark they ramp the load from 100 to 5,000 queries per second over 30 seconds, comparing a pool of 500 TCP connections against 50 QUIC connections with 40 streams each:

Metric at peakQUICTCP
Average latency246 ms7,088 ms
Queries processed per second9,7184,749
Queued queries40over 36,000
Client memory91 MB474 MB
CPU time (peak)~970 ms~370 ms

TCP holds up to about 3,000 queries per second and then saturates: queries pile up in the client queue, latency shoots up and the process memory grows from 9 to 474 MB just to hold pending queries. QUIC gets through the whole ramp with stable latency. In exchange it uses more CPU, because it encrypts and processes packets in user space instead of relying on the kernel’s TCP.

What to read carefully

The article is honest about its limitations, and they’re worth keeping in mind before getting excited:

  • It doesn’t fix multi-statement transactions. As soon as you run BEGIN, the pooler reserves a real Postgres connection for you until COMMIT. If there are three 30 ms round trips and application logic in between, that connection is locked no matter how you arrive. For that the solution is still the usual one: stored procedures, CTEs or moving the code closer to the database.
  • With heavy queries, the advantage fades. If a query takes 5 seconds in Postgres, 30 ms of network is noise.
  • If the application sits next to the database, the good old TCP pool is already enough.

And there are a couple of things I’d add, because I think they put the numbers in context:

  • The benchmark ran on a single machine: client, PgCat and Postgres in containers on the same workstation, sharing CPU and memory. It’s not clear how the 30 ms latency from the scenario that drives the whole article was reproduced, so I’d take the figures as a demonstration of the mechanism rather than a prediction for production.
  • The comparison isn’t entirely like-for-like: 500 TCP connections against 2,000 streams of capacity. Part of the difference comes from having four times more concurrency available, which is exactly what QUIC makes cheap, but it’s worth knowing when reading the table.
  • You need a client and a pooler that speak QUIC. Today that means a PgCat fork and a custom Rust client. psql, libpq and the usual drivers still speak TCP.

It’s also worth remembering that since version 14 Postgres has pipeline mode in libpq, which lets you send several queries in a row without waiting for each response. It doesn’t solve the same thing, since queries still run in order on a single connection and one slow result delays the ones after it, but it tackles the same problem of paying network latency on every query.

What I take away

Even though it’s a prototype, I find the article very useful for its diagnosis more than for its solution. Often, when something is slow, the first place we look is the database: execution plans, indexes, statistics. And sometimes the problem is that the client can’t send queries fast enough, something you can see with a simple division between connections and latency.

That calculation, Little’s Law, is what I take away most. Before growing the pool, switching poolers or thinking about QUIC, it’s worth working out how many queries per second your application can push with the connections it has and the latency to the database. Very often that number alone explains why the dashboard says Postgres is slow when it isn’t.