PgBouncer Scaling Starts When One Core Stops Being Enough
PgBouncer is usually introduced as a way to save Postgres from connection churn. Applications open fewer direct database connections, short-lived clients get pooled, and Postgres spends more time doing database work instead of process and session bookkeeping.
That story is true, but it hides a second bottleneck.
PgBouncer itself is single-threaded. One PgBouncer process can use one CPU core. Put it on a 16-vCPU instance and the operating system may report a large machine, but the pooler is still fundamentally one event loop doing one core’s worth of work. When concurrency rises, that single core can become the limit while most of the box sits idle.
ClickHouse’s Managed Postgres team published a practical way around that ceiling: run multiple PgBouncer processes on the same host, let them all listen on the same port with so_reuseport, and connect the processes with PgBouncer peering so cancellation requests still find the right session.
The interesting part is not just the benchmark result, though the result is large. The interesting part is the shape of the fix. It is a reminder that “add a pooler” is not the end of the architecture. At scale, the pooler is another service with its own concurrency model, correctness traps, and capacity plan.
Why One PgBouncer Process Leaves Hardware Idle
Postgres connections are expensive enough that pooling is often worth adding well before the database is truly overloaded. PgBouncer accepts client connections, keeps a smaller set of server connections open to Postgres, and reuses them according to the configured pooling mode.
In transaction pooling, a server connection is returned to the pool after a transaction commits. That makes PgBouncer especially effective for applications that perform many short transactions and do not need a stable backend session between transactions.
But the pooler still has to accept connections, parse protocol messages, hand work to backend connections, track state, enforce limits, and move bytes. A single PgBouncer process does that work in a single thread.
On small machines or low-concurrency workloads, that simplicity is part of the appeal. There is less coordination inside the process and fewer moving parts to reason about. On a larger box, the simplicity becomes a ceiling: the pooler can saturate one CPU while the instance still looks mostly empty in aggregate CPU metrics.
That distinction matters operationally. If a 16-vCPU machine reports 8% or 10% CPU usage, it is easy to assume the pooler has headroom. For a single-threaded service, aggregate CPU can lie. One hot core is enough to cap throughput even when fifteen others are idle.
so_reuseport Turns One Endpoint Into Many Processes
The basic scaling move is straightforward: start a PgBouncer process per useful slice of CPU capacity, then make all of those processes bind the same address and port.
Linux’s SO_REUSEPORT socket option allows multiple processes to listen on the same port. The kernel distributes new incoming TCP connections across those listening sockets. From the client side, nothing changes. The application still connects to one host and one port. Behind that endpoint, different PgBouncer processes receive different client connections.
That is exactly the kind of trick that fits PgBouncer well. You do not need to make PgBouncer internally multithreaded. You can scale horizontally inside one machine by running several independent single-threaded processes. Each process gets its own event loop and can land on its own core.
PgBouncer’s documentation explicitly describes this pattern for a multi-core setup: enable so_reuseport, run multiple instances with distinct per-process settings such as socket directories, and use peering when cancellation semantics matter.
The result is simple from the outside and parallel on the inside:
[pgbouncer]
listen_addr = 0.0.0.0
listen_port = 6432
so_reuseport = 1
peer_id = 1
In a real deployment, each process also needs unique identity and local paths, and each peer must know how to reach the others. The important idea is that the port stays shared while process state stays separate.
The Cancellation Trap
Running a fleet of PgBouncer processes is not just a matter of launching N copies and calling it done.
Postgres query cancellation has an awkward property: the cancel request does not arrive on the same connection as the query being cancelled. It arrives on a new connection with a cancellation key. In a single PgBouncer process, that is manageable because the process has the state needed to match the key to the active query.
With so_reuseport, the kernel may route the cancellation connection to a different PgBouncer process than the one holding the client session. That second process has no local memory of the query. If nothing else is configured, the cancel request can land in the wrong place and fail to do the thing the user asked for.
Peering is the fix. PgBouncer processes can be configured as peers so they can forward cancellation requests to the process that owns the session. The cancel connection can enter through any process, but the fleet cooperates to route it correctly.
This is the part of the design that matters most for production. A connection pooler that is fast but loses cancellation is not transparent plumbing. It changes the behavior of the database path. Long-running queries become harder to stop, timeouts become less reliable, and operators lose an important escape hatch during incidents.
Peering preserves the illusion that there is still one pooler endpoint, even though there are several processes behind it.
Capacity Is A Fleet Budget, Not A Process Budget
Once PgBouncer becomes a process fleet, connection limits have to be treated as aggregate limits.
If every process is given the old full max_client_conn and max_db_connections values, the fleet can accidentally multiply the pressure on Postgres. Sixteen processes each allowed to open the old budget is not a harmless refactor. It is a new capacity plan.
ClickHouse’s setup divides the relevant budgets across the PgBouncer processes. Each process receives a fraction of the total, so the fleet as a whole remains within the intended envelope. That keeps the scale-out from turning into an oversubscription bug.
This is a common failure mode in infrastructure work. A limit that was safe for one instance becomes unsafe when copied into a group. The name of the setting does not change, but the unit of planning does. The old question was “how many connections may this process allow?” The new question is “how many connections may this service endpoint allow across all workers?”
The same thinking applies to observability. A single process metric is no longer enough. You want per-process visibility to catch skew, but you also want fleet-level totals to understand what clients and Postgres experience.
What The Benchmark Showed
ClickHouse compared a single PgBouncer process with a 16-process fleet on identical hardware. The pooler ran on a 16-vCPU AWS c7i.4xlarge; Postgres and the load generator were on separate machines. The workload used pgbench in select-only mode with transaction pooling, and client concurrency was ramped from 8 to 256.
At low concurrency, the single process was fine. With only 8 clients, there was little parallel work to distribute, and the overhead of the fleet did not help.
The gap opened as concurrency increased.
The single process reached about 86k transactions per second around 64 clients, then fell as load rose further. At 256 clients it was down around 77k transactions per second. That is the classic shape of a saturated single-threaded bottleneck: once the core is full, adding clients mostly adds contention and waiting.
The fleet kept scaling much longer. It reached roughly 219k transactions per second at 64 clients, about 321k at 128 clients, and about 336k at 256 clients. In the measured setup, the multi-process configuration delivered about four times the throughput of the single process at the high-concurrency end.
CPU measurements told the same story from another angle. The single process pinned about one core while the 16-vCPU box stayed mostly idle. The fleet used much more of the machine, climbing into several busy cores until Postgres and the load generator became the next limits.
That last clause is important: the fleet does not remove all bottlenecks. It moves the pooler out of the way until another component becomes the limit. That is what good infrastructure plumbing should do.
Why This Is Better Than Just Adding Another Proxy Layer
Another way to scale a single-threaded service is to put a load balancer in front of many instances. That can work, but it often creates another operational object to deploy, monitor, secure, and reason about.
The so_reuseport approach keeps the client-facing endpoint local and simple. The kernel distributes connections across local workers. PgBouncer handles the database-specific correctness issue through peering. The application does not need to know about process count, and Postgres still sees a controlled pool of backend connections.
That does not make the pattern free. You still need per-process configuration, consistent versions, restart behavior, health checks, metrics, and a clear rollout plan. But the moving parts are close to the problem: PgBouncer processes, their shared port, and their peer relationships.
For managed services, that is a reasonable trade. The platform can hide the fleet behind one endpoint and ship the multi-process design by default. Users get the capacity benefit without learning the mechanics on day one.
For self-managed teams, it is a pattern worth understanding before the pooler becomes the mystery bottleneck during a traffic spike.
When A Single Process Is Still Fine
The lesson is not “always run as many PgBouncer processes as cores.”
If your application has modest concurrency, a single PgBouncer may be simpler and perfectly adequate. If Postgres is already the bottleneck, multiplying pooler processes will not make queries cheaper. If your workload relies on session semantics, transaction pooling may be the wrong mode. If your monitoring cannot distinguish per-process saturation from aggregate host CPU, adding more processes may make troubleshooting harder until observability catches up.
There are also version and configuration details to respect. Peering support exists to preserve cancellation behavior in multi-process or load-balanced setups, but all peers need to be configured coherently. Mixed behavior across PgBouncer versions or inconsistent peer settings can turn a scaling change into a correctness problem.
The practical threshold is straightforward: scale PgBouncer when the pooler is the bottleneck. Prove that with metrics. Look for one process saturating a core, rising wait time, throughput flattening while Postgres still has headroom, and client pressure increasing without corresponding database work.
Then scale the pooler deliberately.
The Broader Pattern
This PgBouncer design is a small example of a larger infrastructure pattern: single-threaded components often survive longer than people expect because they are fast, simple, and reliable. Then one day they become the narrowest point in a system that has grown around them.
The answer is not always to rewrite them. Sometimes the right answer is to keep the single-threaded unit and compose more of it:
- give each worker a share of the machine,
- let the operating system distribute entry traffic,
- add just enough coordination for correctness,
- divide global limits across workers,
- observe both the worker and the fleet.
That is a more conservative design than replacing the component outright. It keeps the mature behavior of PgBouncer while making better use of modern multi-core hosts.
The ClickHouse numbers are useful because they make the hidden ceiling visible. A 16-vCPU box running one PgBouncer process is not really a 16-vCPU pooler. It is a one-core pooler on a 16-vCPU bill. A peered so_reuseport fleet turns more of that bill into actual capacity.
The cleanest version of this lesson is simple: connection pooling is not magic. It is software in the request path. Once it becomes important enough, it deserves the same capacity modeling and correctness review as everything else in production.