When application services sit tens of milliseconds away from their database infrastructure, connection pooling behavior changes. Connection poolers like PgBouncer and PgCat protect Postgres from backend connection saturation, but client-side connection pools can still back up when requests face round-trip network delays.
Recently, we set up an experiment to observe what happens when we replace client-side TCP connection pooling with QUIC streams. We modified PgCat to accept QUIC connections and ran the Postgres wire protocol over multiplexed bidirectional streams across a simulated 30ms latency link.
This post documents the experiment, the mechanics of client-side pooling over latency, the telemetry we recorded, and our observations across different workloads. Database CPU, lock contention, and multi-statement transaction constraints remain unchanged. What shifts is how in-flight queries travel between the application client and the connection pooler.
Postgres Wire Protocol Mechanics
The PostgreSQL frontend/backend protocol (Protocol 3.0) is fundamentally synchronous per connection.
When a client sends a Query or Execute message down a Postgres socket, that channel is occupied until the server finishes processing and returns the matching CommandComplete and ReadyForQuery messages. You cannot interleave two independent queries from different application threads on the same physical connection without serializing them one after the other.
Protocol Behavior: 1 connection channel = 1 active in-flight query or transaction. The connection is occupied from the moment the query bytes leave the client until the final row batch and ReadyForQuery return across the wire.
Because running thousands of concurrent Postgres backend processes on the database host causes substantial context switching, memory consumption, and catalog lock contention, standard practice is to place a connection pooler in front of the database.
Network Topology: 30ms WAN and 2ms LAN
In distributed setups, clients often live across network boundaries from the primary database cluster. Regional application nodes, edge functions, or cross-cloud services might experience 20ms to 40ms of latency to the database region, while the connection pooler sits directly adjacent to Postgres inside the same private network.
Consider a typical topology:
- Client to Pooler (WAN / Inter-region): Round-trip time (RTT) is 30ms.
- Pooler to Postgres (Internal LAN / Same VPC): Round-trip time is <2ms.
- Query execution time inside the Postgres engine: A lightweight point lookup takes 0.5ms.
Figure 1: The Asymmetric Latency Pipeline
Client Pool
pool_size = 10
Each query occupies 1 connection for ~30.5ms total waiting on network wire flight
PgCat Pooler & Postgres
<2ms internal LAN + 0.5ms execute
The Round-Trip Latency Bottleneck:
The database engine executes the query in 0.5ms and the internal pooler recycles the connection in 2ms. But the client socket cannot be reused for 30.5ms because the bytes are still flying over the WAN link. Throughput is capped at 10 connections / 0.030s = 333 QPS.
Client Connection Counts and Round-Trip Delay
Under this model, the maximum query throughput a single client instance can dispatch over its network link is bounded by Little's Law:
If the client pool is limited to 10 TCP connections to prevent socket exhaustion, the client can dispatch at most 10 queries every 30 milliseconds.
When an application burst of 500 queries hits that client process:
- The first 10 queries occupy the 10 TCP sockets and begin their 30ms transit.
- The remaining 490 queries must sit in the client application's in-memory queue (such as Tokio task queues or connection pool mutexes).
- At a drain rate of 333 QPS, the 500th query waits in client memory for approximately 1.5 seconds before its request bytes are transmitted over the wire.
- From the perspective of application response time, latency appears elevated, even though the database engine executed the query in under a millisecond.
Scaling Client TCP Connections
When increasing client-side connection pools to 500 or 1,000 TCP sockets, several system factors come into play:
1. OS Sockets & File Descriptors
Each TCP connection requires an operating system file descriptor. When multiple application containers or processes scale on a host, high connection counts increase file descriptor consumption and kernel epoll tracking overhead.
2. Kernel Buffer Memory
The Linux kernel allocates transmit and receive buffers (tcp_wmem and tcp_rmem) for each active socket. Hundreds or thousands of concurrent TCP sockets allocate kernel memory even during idle periods.
3. Connection Setup Overhead
Establishing new TCP connections requires a 3-way handshake plus a TLS handshake. Over a 30ms link, establishing a fresh connection requires multiple round trips before application data can be sent.
4. Head-of-Line Blocking
If a packet drops on a single TCP connection, TCP window processing halts for that entire stream until the missing segment is retransmitted, delaying any subsequent bytes queued on that socket.
Stream Multiplexing Over QUIC
QUIC provides natively multiplexed, independent bidirectional streams over a single connection. In our experiment, we wanted to see how shifting the client-side pooling model from OS sockets to QUIC streams altered queue behavior over latency:
- Streams without new OS sockets: Opening a stream in QUIC does not allocate a new file descriptor or require an OS-level socket creation. It is managed as a framed stream ID inside an existing encrypted connection.
- Independent stream flow: If packet loss occurs on one stream, only that stream's reassembly pauses. Other active streams on the same QUIC connection continue transferring data independently.
- Reduced client pooling overhead: Instead of managing hundreds of raw TCP sockets, the client maintains a smaller number of persistent QUIC connections (such as 50) and allocates streams dynamically as queries arrive.
- 0-RTT Resumption and Postgres Handshakes: QUIC supports 0-RTT session resumption using TLS 1.3 session tickets, allowing clients to re-establish transport state without an initial round trip after connection drops. However, in a database context, transport resumption does not eliminate application-level initialization. The client must still complete the Postgres handshake (sending the
StartupMessagewith credentials and awaitingAuthenticationOkandReadyForQuery) before queries can execute. While not implemented in this prototype, 0-RTT could reduce transport reconnect latency, but database-level authentication remains necessary.
Figure 2: TCP Connection Overhead vs QUIC Stream Multiplexing
High kernel memory footprint, socket table limits, and kernel buffer overhead per connection.
Zero kernel socket allocation per query. In-memory framing over reused persistent UDP connections.
Database State and Multi-Statement Transactions
QUIC operates purely at the transport layer. It does not alter database execution semantics or remove database constraints.
The database engine must still execute SQL, manage locks, read from disk or shared buffers, and maintain transactional consistency. Multiplexing network transport does not speed up slow queries or make the database engine faster.
Multi-Statement Transactions
The distinction between transport multiplexing and database state is most visible in multi-statement transactions. Consider an interactive transaction executed across a network link:
-- 30ms WAN roundtrip to pooler
2. SELECT balance FROM accounts WHERE user_id = 42;
-- 30ms WAN roundtrip
-- Client application computes business logic (10ms)
3. UPDATE accounts SET balance = balance - 100 WHERE user_id = 42;
-- 30ms WAN roundtrip
4. COMMIT;
During this sequence:
- When
BEGINexecutes, the connection pooler must pin a physical Postgres backend connection to that specific client session. - While the client waits across multiple network round trips (totaling ~90ms) and performs application-side computation, that physical backend database connection remains pinned and unavailable to any other client.
- QUIC does not change this behavior. Even if a client opens thousands of concurrent QUIC streams, if those streams initiate interactive multi-statement transactions, the backend database pool will exhaust its available connections immediately.
For workloads dominated by multi-statement transactions over WAN, the appropriate solutions remain architectural: using CTEs, stored procedures, or placing application logic close to the database. Transport multiplexing is primarily relevant for high-concurrency single-statement query traffic.
Tokio-Postgres and PgCat Setup
To run this experiment, we tested whether existing Postgres client drivers could interface with QUIC streams without protocol modifications.
In the Rust ecosystem, tokio-postgres provides a low-level constructor: connect_raw(stream, tls_mode).
AWS's s2n-quic library exposes bidirectional streams via s2n_quic::stream::BidirectionalStream. Because this type implements Tokio's standard AsyncRead, AsyncWrite, and Unpin traits, tokio-postgres accepts it as a raw byte transport.
// Connecting tokio-postgres directly over an s2n-quic bidirectional stream
use s2n_quic::Client;
use std::net::SocketAddr;
use tokio_postgres::NoTls;
// 1. Establish or reuse the underlying QUIC connection (TLS 1.3 is native)
let connect = s2n_quic::client::Connect::new(pooler_addr)
.with_server_name("pg-pooler.internal");
let mut connection = quic_client.connect(connect).await?;
// 2. Open an independent bidirectional stream over the established connection
// No OS socket creation, no 3-way handshake, no new file descriptor.
let stream = connection.open_bidirectional_stream().await?;
// 3. Hand the raw stream to tokio-postgres.
// Because BidirectionalStream implements AsyncRead + AsyncWrite + Unpin,
// tokio-postgres treats it as a drop-in byte stream.
let (pg_client, pg_connection) = pg_config.connect_raw(stream, NoTls).await?;
// Drive the Postgres connection background worker
tokio::spawn(async move {
if let Err(e) = pg_connection.await {
eprintln!("Postgres connection error on stream: {}", e);
}
});
// Execute standard query over the isolated QUIC stream
let rows = pg_client.query("SELECT id, username FROM users WHERE id = $1", &[&user_id]).await?;
In the snippet above, NoTls is passed to connect_raw because QUIC mandates TLS 1.3 at the transport layer. All payload data inside the stream is already encrypted by QUIC, making nested TLS redundant.
On the pooler side, we modified PgCat to bind an s2n-quic listener alongside its standard TCP listener. When a client opens a QUIC stream, PgCat accepts the stream, processes the Postgres startup packet, and feeds the stream into its existing transaction pooling logic.
// Managing streams across a pool of QUIC connections
pub struct QuicManager {
connections: Vec<QuicConnectionWrapper>,
streams_per_connection: u64,
}
impl QuicManager {
pub async fn acquire_stream(&self) -> Result<s2n_quic::stream::BidirectionalStream> {
// Find an established connection with capacity < streams_per_connection
for conn in &self.connections {
if conn.active_streams.load(Ordering::Relaxed) < self.streams_per_connection {
conn.active_streams.fetch_add(1, Ordering::Relaxed);
return conn.connection.open_bidirectional_stream().await;
}
}
// If all existing connections reach capacity, open a new QUIC connection
self.spawn_new_quic_connection().await
}
} Benchmark Setup and Telemetry
Benchmark results reflect specific test environments. Running the same tests on different hardware, different kernel configurations, or different query profiles will yield different numbers. Below are the exact conditions under which this benchmark was conducted.
Environment & Workload Parameters
- Host System: AMD Ryzen 7 5700X3D (8 cores, 16 threads), 64 GB RAM, Linux 6.18 kernel.
- Container Runtime: Client, PgCat, and PostgreSQL ran containerized via Podman and Docker.
- Client Networking: Release build compiled statically with
musl, executed using--network=host. - Server Bridge: PgCat and Postgres hosted on an isolated bridge network (
172.20.0.8). - Resource Sharing: Benchmark driver, pooler, and database ran on the same physical host.
- Load Profile: Ramped linearly from 100 QPS to 5,000 QPS over 30 seconds.
- Simulated Latency: 30ms round-trip latency injected into the client link via
netem/tc. - Test Query:
SELECT trunc(random() * 100) FROM generate_series(1, 10)(~0.5ms database time). - TCP Setup: 500 max client connections using
tokio-postgres-rustlswith TLS enabled. - QUIC Setup: 50 physical connections capped at 40 streams each (2,000 stream capacity).
- Telemetry: 1-second sampling of
/proc/$pid/statand cgroup metrics.
Query Profile Context
The test query was intentionally selected to be lightweight (~0.5ms engine time) to isolate transport concurrency and connection acquisition from database execution time. In production environments where queries take 20ms or 100ms inside Postgres, execution time will represent the dominant fraction of total response time, and the relative impact of transport multiplexing will be less pronounced.
The telemetry data collected at peak load showed the following metrics:
| Metric | TCP (500 Connections) | QUIC (50 Conns, 40 Streams) | Observation |
|---|---|---|---|
| Peak Avg Latency | 7,088 ms | 246 ms | TCP experienced queue delay as requests waited for available sockets. |
| Completed QPS | 4,749 | 9,718 | QUIC drained incoming queries without client-side queuing. |
| Stalled / Backlogged Queries | 36,041 | 40 | TCP client queue grew when arrival rate exceeded 500 / 0.030s capacity. |
| Client Memory (RSS) | 474 MB | 91 MB | TCP memory increased holding backlogged query buffers in runtime memory. |
| Client CPU Time | ~370 ms | ~970 ms | QUIC incurred higher user-space CPU for packet processing and crypto. |
Figure 3: Recorded telemetry over the 30-second load ramp.
Telemetry Notes
- TCP Queue Dynamics: Past the 20-second mark as load approached the 500-connection limit across 30ms latency, queries began waiting in the client process queue. Average query turnaround time increased as requests spent time queued before socket dispatch. Memory usage rose to 474 MB as the client buffered in-flight closures.
- QUIC Stream Allocation: In the QUIC run, queries were assigned to open streams across the 50 connections up to the 40-stream limit. In-flight queued queries remained low (between 0 and 41) throughout the ramp, with latency remaining consistent between 170ms and 246ms.
- CPU Consumption Tradeoff: QUIC's client-side CPU consumption was higher (~970ms CPU vs ~370ms for TCP). User-space packet framing and crypto parsing in
s2n-quicrequire more CPU cycles than kernel-offloaded TCP. Processing higher completed throughput also naturally increases CPU load.
Observations Across Workloads
Evaluating where transport multiplexing shows measurable differences versus where it provides little value:
Where Differences Appeared
- High-Latency WAN Links: Distributed application runtimes, edge workers, or cross-region services executing single-statement queries against a distant pooler.
- High Client Concurrency: Environments where maintaining thousands of open client TCP sockets strains OS file descriptors or kernel buffer limits.
- Unreliable Network Paths: Links prone to packet loss where independent stream multiplexing avoids head-of-line blocking stalls.
Where Behavior Remained Unchanged
- Co-located Services: Applications and databases within the same local data center or rack (<1ms latency) where TCP pooling is already fast and efficient.
- Computationally Heavy Queries: Queries that execute for tens or hundreds of milliseconds inside Postgres, where network round-trip time is a small fraction of total latency.
- Interactive Multi-Statement Transactions: Workloads with multi-step transactions that hold backend Postgres connections across network round trips.
Summary
This experiment examined whether running the Postgres wire protocol over QUIC streams could address client-side connection queuing when dealing with network latency.
The observations show that stream multiplexing can prevent client-side queue pileups during concurrency spikes over high-latency paths, without altering the underlying Postgres wire format. However, it is not a fix for database performance bottlenecks. Database capacity, query execution times, and transaction mechanics remain the determining factors in database scalability.
The trade-off is clear: QUIC exchanges additional user-space CPU usage for lower client-side connection overhead and reduced queuing over network latency. Whether that tradeoff makes sense depends entirely on your specific workload, latency profile, and infrastructure architecture.
Repository and Code
The modified PgCat pooler supporting QUIC and the Rust benchmark suite are open source.
- ✓ The PostgreSQL wire protocol is inherently synchronous per stream: queries cannot execute concurrently over a single stream without serialization.
- ✓ Connection poolers optimize server-side database connections, but client-side connection pools can still back up when facing network latency.
- ✓ Across an artificial 30ms link, a client pool with 10 connections dispatches up to 333 queries per second before queuing requests in local memory.
- ✓ QUIC allows opening lightweight bidirectional streams inside an existing connection without allocating new OS sockets or file descriptors.
- ✓ QUIC does not solve the database bottleneck: query execution time, lock contention, and multi-statement transactions that hold backend connections remain unchanged.
- ✓ In a synthetic test isolating network round-trips with sub-millisecond queries, QUIC avoided client queue buildup, but increased user-space CPU consumption.