One of the first things we usually do when building a Java application that talks to a relational database is configure a connection pool. The reason is straightforward: opening a database connection is expensive, and an application runs a huge number of database operations over its lifetime. Paying that price every single time is not something we want to do. With PostgreSQL the cost is even more tangible than with other databases, because every connection is served by its own backend process on the server. Each one carries its own memory, its own share of the scheduler, and its own contribution to lock and snapshot bookkeeping, which is why max_connections starts to hurt long before the machine looks busy. So, instead of creating a new connection for every operation, we keep a collection of connections around and reuse them. In the Java ecosystem, HikariCP has become the default implementation of this pattern, and frameworks such as Spring Boot will configure it for us without asking us to think about the details.
The architecture is quite simple. The application borrows a connection from HikariCP, uses it to execute one or more statements, and returns it to the pool. HikariCP takes care of creating connections when necessary, keeping the pool within its configured limits, retiring old connections, and coordinating the threads that are waiting for one. From the application’s perspective, it is a very convenient abstraction: the database connection becomes just another managed resource.
The problem appears when we stop running one application instance and start running many. Suppose our HikariCP pool is configured with a maximum of 20 connections. With a single instance, the application can hold at most 20 database connections. If we deploy ten instances, there are now ten independent pools, each allowed to open up to 20 connections, so we are potentially at 200. If we are in an environment with auto-scaling, and the number of instances grows to, let’s say, 100, the same configuration can end up asking PostgreSQL for 2,000 connections, and nobody changed a single line of configuration to get there.
Nothing strange has happened inside HikariCP. Every instance is behaving exactly as we asked it to behave. The problem is that HikariCP only has local knowledge. Instance A knows how many connections it is using, instance B knows how many it is using, and so on, but there is no shared resource manager coordinating those decisions. The database, on the other hand, is a shared resource. It has a finite amount of CPU, memory, and I/O, and a finite practical limit on how much concurrent work it can do.
This creates a mismatch between two scaling models. Application infrastructure is usually designed to scale horizontally: if traffic increases, we add more instances. Database infrastructure does not scale that way, or at least not as cheaply. Adding another application instance can indirectly increase the pressure on the database for no other reason than that the new instance brings another connection pool with it.
The obvious response is to make the HikariCP pool smaller. Instead of allowing every instance to hold 20 connections, perhaps we configure five. Many teams go one step further and do the arithmetic explicitly: take the connection budget PostgreSQL can afford, divide it by the maximum number of replicas the autoscaler is allowed to create, and use the result as the pool size. It works, but it is fragile. The number has to be recalculated every time someone raises the autoscaler limit, adds a new service against the same database, or runs a deployment that briefly doubles the number of pods during a rolling update. More importantly, it doesn’t change the fundamental relationship between application scaling and database connections. With 100 instances and a pool of five, we are still potentially at 500 connections, and most of them will be sitting idle on instances that happen to be quiet while the busy instances queue for their five. The database connection capacity is still coupled to the number of application instances.
This is where an external connection pooler such as PgBouncer changes the architecture. Instead of every application instance opening its physical connections directly against PostgreSQL, we introduce another layer between the applications and the database.
There are now two different kinds of connections. The connections between the applications and PgBouncer are client connections, while the connections between PgBouncer and PostgreSQL are server connections, the real backend processes. PgBouncer keeps a pool of the latter and multiplexes activity from a much larger number of clients onto a smaller number of actual PostgreSQL connections.
This distinction matters because the number of application-side connections no longer has to match the number of backend processes PostgreSQL has to run. We might have hundreds of application instances and thousands of client connections while deliberately capping the connections PgBouncer opens to PostgreSQL at, let’s say, 50. The pooler becomes the point at which we impose a global limit on the database, rather than relying only on independent limits inside each application process.
It also explains why, contrary to what many people think, HikariCP and PgBouncer are not really competitors. They operate at different layers. HikariCP is a library running inside each JVM, and manages the connections available to that application instance. PgBouncer is an external process, and manages the connections between itself and the database. It is perfectly valid to have both, and it is a very natural setup when we want local connection management and a central limit at the same time: HikariCP stops an individual instance from opening an uncontrolled number of connections, while PgBouncer stops the fleet as a whole from consuming an uncontrolled number of backend processes.
There is a detail I skipped over in the previous paragraphs, and it is the one that decides whether any of this works: PgBouncer’s pool mode. PgBouncer can operate in three modes, and they give very different results.
In session mode, a client connection is assigned a server connection when it connects and keeps it until it disconnects. That is the most compatible mode, but if we put HikariCP in front of it, which opens its connections at startup and keeps them open for as long as it can, we have gained almost nothing. Every Hikari connection pins a PostgreSQL backend, and we are back to 100 instances times 20 connections, now with an extra hop in the middle.
In transaction mode, a server connection is assigned to a client only for the duration of a transaction and goes back to PgBouncer’s pool when the transaction commits or rolls back. This is the mode where the multiplexing really happens, and the reason it works so well is that most application connections are idle most of the time. A Hikari connection spends the bulk of its life waiting between transactions, or waiting on the application to do something with the results. In transaction mode, that idle time no longer costs a backend process.
There is also a statement mode, which releases the server connection after every single statement and therefore forbids multi-statement transactions. It has its uses, but for the typical Java service it is not the mode you want.
Transaction mode comes with a catch, though, and it is one that Java applications run into quite frequently. Because consecutive transactions from the same client connection can land on different server connections, anything that relies on session state stops being reliable. A SET executed outside a transaction, session-level advisory locks, LISTEN/NOTIFY, temporary tables that outlive a transaction, all of them can silently end up on a different backend than the one we expect. The classic trap is prepared statements. The PostgreSQL JDBC driver switches to named server-side prepared statements after a statement has been executed a few times (controlled by prepareThreshold, five by default), and historically that produced errors such as prepared statement "S_1" does not exist when the next execution landed on a different backend. For years the answer was to add prepareThreshold=0 to the JDBC URL and give up server-side prepared statements. Since PgBouncer 1.21, setting max_prepared_statements makes PgBouncer track protocol-level prepared statements itself and re-prepare them transparently on whatever server connection the client lands on, which removes most of the pain.
As a reference, a minimal setup could look something like this:
; pgbouncer.ini[databases]orders = host=postgres.internal port=5432 dbname=orders[pgbouncer]listen_port = 6432pool_mode = transactionmax_client_conn = 5000 ; client connections PgBouncer will acceptdefault_pool_size = 40 ; server connections per database/user pairreserve_pool_size = 5query_wait_timeout = 30 ; seconds a client may wait for a server connectionserver_lifetime = 3600server_idle_timeout = 600max_prepared_statements = 200 ; PgBouncer 1.21+
And on the application side:
spring: datasource: # With PgBouncer < 1.21 add ?prepareThreshold=0 to the URL url: jdbc:postgresql://pgbouncer.internal:6432/orders hikari: maximum-pool-size: 20 connection-timeout: 5000 max-lifetime: 1800000 idle-timeout: 600000
Notice that default_pool_size is per database and user pair, not per PgBouncer instance as a whole, so every service that connects with its own credentials gets its own pool of server connections. The real ceiling on PostgreSQL is the sum of those pools.
There is a price for introducing the second layer. We now have two systems managing database connectivity, and their configuration and behaviour have to be considered together. Connection limits, timeouts, connection lifetimes, and failure behaviour exist at both layers, and they interact in ways that are not obvious at first. Take waiting as an example. A request thread in our service first waits for HikariCP to hand it a connection, bounded by connection-timeout. Once it has one, the first statement of the transaction may wait again inside PgBouncer for a server connection, bounded by query_wait_timeout. That is two queues, with two timeouts, surfacing two different errors, and whoever is on call at three in the morning needs to know which one fired. The same happens with lifetimes: Hikari’s max-lifetime now governs the cheap connection to PgBouncer, while server_lifetime governs the expensive one to PostgreSQL, and tuning one does nothing for the other.
That queue inside PgBouncer is also worth pausing on, because it is already a form of backpressure. When all server connections are busy, clients do not get new backends, they wait, and after query_wait_timeout they are rejected. That is admission control at the database boundary, and it is one of the main reasons a central pooler protects the database better than a hundred independent pools.
And there is one more detail that is easy to miss: the limit is only global if PgBouncer itself is central. PgBouncer is single-threaded, so at high throughput teams run several instances, and each one maintains its own pools. Five PgBouncer instances with default_pool_size = 40 means up to 200 server connections, not 40. Some teams also deploy PgBouncer as a sidecar in every application pod, which is convenient but brings us right back to the original multiplication problem, just one layer further down. Where the pooler runs is as much an architectural decision as whether we run one at all.
If you are on a managed PostgreSQL offering, a lot of this may already be available as a service. Amazon RDS Proxy, Supabase’s Supavisor, and the built-in PgBouncer in Azure Database for PostgreSQL are all variations of the same idea, and the same questions about pool modes, session state, and timeouts apply to them.
PgBouncer sits at the PostgreSQL protocol boundary. It does not need to know whether the application using it is written in Java, Go, or Python. As long as the client speaks the PostgreSQL protocol, PgBouncer can sit in the middle. That is one of the reasons this architecture works so well in organisations where many services and languages share the same PostgreSQL infrastructure.
Other PostgreSQL proxies take this idea further. PgCat, for example, combines connection pooling with health checking, load balancing, failover, and read/write splitting. PgDog, written by one of PgCat’s original authors, is a multi-threaded proxy that provides pooling and routing, including support for sharding. These are not alternative implementations of HikariCP. They are PostgreSQL-aware infrastructure components that sit at the database boundary and can make decisions based on the topology and behaviour of the PostgreSQL cluster.
Here, the proxy is no longer only a mechanism for reducing the number of database connections. It becomes part of the database topology, deciding where different types of traffic should go and reacting when one of the nodes becomes unavailable.
For a long time, these were usually the options people compared and combined when making this decision. Recently, though, Open J Proxy (OJP) reached version 1.0.0, its first production-ready release (JavaPro article).
OJP approaches the problem from a different direction. Rather than being a proxy for one database protocol, it is built around the Java/JDBC boundary. The application uses the OJP JDBC driver, which talks to an OJP server over gRPC, and the OJP server becomes the component responsible for the actual database connections. Because it sits at the JDBC level instead of the wire protocol, it is not tied to PostgreSQL; the same server can front PostgreSQL, MySQL, MariaDB, Oracle, SQL Server, DB2, and others with a JDBC driver, which is a real difference from everything else in this article. The flip side is that it only helps Java clients. A Python batch job or a Go service hitting the same database will go around it.
The OJP server uses HikariCP by default (DBCP is also available), so the underlying pooling mechanism has not disappeared; what has changed is where the pool lives. With a normal deployment, every application instance has its own HikariCP pool; with OJP, the physical pool is centralised in the OJP server. The OJP driver’s connections are virtual, and putting HikariCP on top of them would add a queue that knows nothing about the real limit, so it makes sense to remove the application-side pool and let the OJP server be the single place where connections are managed.
So OJP is not replacing HikariCP with another implementation of the same abstraction. It moves the physical connection pool out of the application instances and into a shared service. The application still talks to the database through JDBC, but the physical connection becomes an infrastructure concern rather than something each JVM manages on its own. In that sense, OJP and HikariCP are not mutually exclusive either; it is closer to OJP wrapping HikariCP than to OJP versus HikariCP.
This lets us look at database connectivity as an admission-control problem. Suppose the database can safely sustain a certain amount of concurrent work. It doesn’t really matter whether that work comes from ten application instances or one hundred; what matters is the total amount reaching the database, and a central component can enforce that limit across the whole fleet.
It is fair to say, though, that PgBouncer already gives us that global point, as we saw with its wait queue. So the interesting question is not whether OJP can limit concurrency (both can) but what it can do because it sits at the JDBC level rather than at the wire protocol. Because the server sees JDBC operations with the context of the datasource they come from, it can make decisions a protocol-level pooler has a harder time making. One example is its slow query segregation, which separates slow and fast operations into different lanes so that a handful of heavy reporting queries cannot occupy every connection and starve the quick transactional ones. Features like backpressure, concurrency limits, circuit breaking, and query monitoring are not different ways of pooling connections; they are mechanisms for controlling the relationship between an elastic application layer and a comparatively constrained database layer, and the layer we put them in determines how much they know.
OJP deserves the same critical look we gave PgBouncer, though. Every database call now makes an additional network hop, from the application to the OJP server over gRPC, before it even reaches the database, and that latency is paid on every statement, not only when connections are opened. The OJP server is also now on the critical path for every Java service using it, so it needs to be deployed with redundancy, scaled, monitored, and upgraded like any other piece of shared infrastructure. None of this is unique to OJP; a central PgBouncer has exactly the same concerns, but it is important not to forget that centralising the pool also means centralising a failure point.
The important change is not that OJP has a pool and PgBouncer has a pool. Both do. The question is what the component surrounding that pool knows about and what responsibilities it has. HikariCP manages a pool inside one Java process. PgBouncer manages PostgreSQL connections at the protocol boundary. PgCat and PgDog add topology and routing concerns. OJP puts the pool behind a Java-aware service and can provide application-level controls around database access.
Do we actually need several layers? There is no universal answer. HikariCP plus PgBouncer is a good fit when we want local pooling in each service and a central limit that works for any language. OJP provides a similar centralisation model for Java applications while adding controls at the JDBC boundary. A PostgreSQL-specific proxy such as PgCat or PgDog may be preferable when the central problem is topology, such as routing reads to replicas or distributing work across shards.
What I would avoid is stacking all of them blindly. It is technically possible, but probably not a good idea. For example, we could build something like Java -> OJP -> PgBouncer -> PostgreSQL, but now there are two components managing connection limits, queueing, timeouts, and connection lifetimes. There may be valid reasons for it, for example, if OJP provides application-level controls while a PostgreSQL proxy handles routing to replicas, but adding another pool does not automatically improve the system. Each additional layer is another place where requests can queue, another set of timeouts to understand, and another failure mode to reason about. The same applies to PgBouncer, PgCat, and PgDog among themselves. They occupy roughly the same boundary, so in most architectures we would choose the one whose capabilities match our problem rather than chaining them.
The deeper problem behind all of these technologies is not connection pooling. Connection pooling is the mechanism we started with because database connections are an expensive and finite resource. Once the application becomes a distributed system, however, we discover that the resource is shared by many independent processes, while the original pool was designed to manage only one.
That leads to a more general distributed-systems problem: how do we govern a shared, finite resource when the clients consuming it are elastic?
- For a small Java application, the answer can remain entirely local:
Application -> HikariCP -> Database. - As the fleet grows, we may introduce a PostgreSQL-level pooler:
Application Fleet -> HikariCP pools -> PgBouncer -> Database. - If database topology becomes part of the problem, a PostgreSQL-aware proxy can take responsibility for routing and failover:
Application Fleet -> PgCat / PgDog -> [Primary | Replicas]. - And if the environment is predominantly Java, and we want database access itself to be a centrally managed capability, we can move the physical pool behind OJP:
Application Fleet -> OJP (HikariCP) -> Database.
The architectural decision is not which connection pool to use. It is where we want the responsibility for database resource management to live. HikariCP places it inside each application instance. PgBouncer places it at the PostgreSQL protocol boundary. PgCat and PgDog extend that boundary into routing and topology management. OJP moves it into a shared, Java-aware infrastructure layer while still using HikariCP underneath.
Once we look at it this way, these technologies stop looking like competing connection pools and start looking like different answers to the same scaling problem: the application layer wants to scale independently, while the database remains a shared and finite resource.
The connection pool is simply the first place where that tension becomes visible.



