The idea: open once, lend many times

A web request usually needs the database for a few milliseconds. Opening a new database connection takes longer than that: a TCP handshake, a login, and in PostgreSQL a whole new operating-system process. A connection pool opens a few connections once and keeps them. Each request borrows one, runs its queries and returns it, and the next request reuses it.

Timelines of three requests with a 3 ms query: without a pool each one spends 12 ms on TCP, startup and fork, and SCRAM login before its query, 15 ms each; with HikariCP only the first opens connection C1 and the next two take 3 ms on the same connection
Without a pool every request pays 12 ms to open a connection for a 3 ms query; with a pool only the first one does.

The page shows a Java application with six request threads (T1–T6, like Tomcat worker threads) and a PostgreSQL server. Every message between them flies in its own row. One tick of the clock is 1 ms and the network takes 1 ms each way. Pick HikariCP pool or no pool, then press Request, Burst ×6 or Leak, or run a demo.

What opening a connection costs

Without a pool, DriverManager.getConnection() does all of this for every request (Demo: no pool, open and close every time):

StepMessagesTime here
TCP handshakeSYN, SYN-ACK, ACK2 ms
StartupStartupMessage user=app database=shop; the postmaster forks a backend process1 + 3 ms
SCRAM-SHA-256 loginAuthenticationSASL, SASLInitialResponse, SASLContinue, SASLResponse, then AuthenticationOk … ReadyForQuery6 ms
Total to open12 ms
The query itselfQuery → RowDescription, DataRow, CommandComplete, ReadyForQuery3 ms
CloseTerminate; the backend process exits; TCP FIN2 ms

So a 3 ms query costs 15 ms, and the database forks and kills a process for each request. With TLS (sslmode=require) add one or two more round trips. Over a real network to another data centre each round trip can be tens of milliseconds, which makes the difference far bigger.

Borrow and return

With a pool, dataSource.getConnection() takes an idle connection and returns at once: no network, no new process. connection.close() does not close the socket. It gives the connection back to the pool as idle, and the backend process on the server stays alive for the next borrower. In Demo: pool, open once then reuse the first request takes 15 ms (the pool has to open C1) and the next two take 3 ms each, all on C1 and the same PostgreSQL process.

try (Connection c = dataSource.getConnection();          // borrow
     PreparedStatement ps = c.prepareStatement("SELECT * FROM orders WHERE id = ?")) {
    ps.setLong(1, id);
    ...
}                                                        // close() = return to the pool

A pool is a counting semaphore with maximumPoolSize permits, plus a list of open connections.

When the pool is empty: waiting and connectionTimeout

If no connection is idle and the pool is below maximumPoolSize, HikariCP opens a new one in the background. If the pool is already full, the thread blocks in getConnection(). A returned connection goes straight to the first waiting thread. In Demo: 6 requests, pool of 3 the pool opens only 3 connections; T1–T3 finish after 15 ms and T4–T6 after 18 ms. PostgreSQL never sees more than 3 connections.

Pool of 3: threads T1 to T3 hold connections C1 to C3, each tied to a PostgreSQL backend process; T4, T5 and T6 are blocked in getConnection in a queue; a returned connection goes to T4, and if none comes back within connectionTimeout the waiter gets an exception
With maximumPoolSize = 3, the fourth request waits in getConnection() for a returned connection, or fails after connectionTimeout.

A thread waits at most connectionTimeout (30 s by default, 50 ms here). Then getConnection() throws SQLTransientConnectionException: Connection is not available, request timed out. In Demo: slow queries, waiters time out each query holds its connection for 60 ms, so T4–T6 give up at 50 ms while the database itself is far from busy. The fix is usually faster queries or shorter transactions, not a bigger pool.

Connection leaks

A thread that never calls close() (an exception jumped over it, no try-with-resources) keeps its connection forever. The pool cannot take it back. In Demo: a connection leak drains the pool three leaks fill the pool of 3, and the next request times out after 50 ms, although PostgreSQL shows three idle processes. leakDetectionThreshold makes HikariCP log a warning with the stack trace of the code that borrowed the connection, once it has been out longer than the threshold. It only reports the leak; it does not fix it.

How big should the pool be?

PostgreSQL has its own limit, max_connections (100 by default, 5 here). Without a pool, a burst of requests opens a connection each and the extra ones fail with FATAL: sorry, too many clients already (SQLSTATE 53300; Demo: no pool, 6 at once). With pools, the sum over all application instances counts: 10 instances × a pool of 20 = 200 connections.

More connections do not mean more work done. The database has a fixed number of CPU cores and disks; past that, extra connections only add context switches and lock contention. HikariCP's guide suggests starting near connections = (cores × 2) + effective_spindle_count and measuring. A thread waiting a millisecond for a connection is cheaper than a query slowed down by a crowded server. Little's Law gives the other side: connections needed ≈ queries per second × seconds each query holds a connection (see Latency vs Throughput). Connection Pool Sizing runs pools of 2, 8 and 32 connections side by side against the same database.

Retiring connections

The pool also closes connections on purpose:

  • idleTimeout (10 min by default, 200 ms here): a connection idle that long is closed, but the pool keeps at least minimumIdle. Demo: idle connections are closed closes all three (minimumIdle is 0 here).
  • maxLifetime (30 min by default, 500 ms here): every connection is replaced after this age, before a firewall, load balancer or the database drops it silently. A connection in use is closed when it is returned.
  • keepaliveTime pings idle connections so that idle sockets are not cut by the network.

HikariCP's housekeeper checks this every 30 seconds; on this page the checks run at the exact moment, or when a connection is borrowed or returned.

HikariCP parameters on this page

ParameterMeaningDefaultHere
maximumPoolSizemost connections, idle + in use102 / 3 / 5
minimumIdleidle connections the pool tries to keep= maximumPoolSize0
connectionTimeoutlongest wait in getConnection()30 000 ms50 ms
idleTimeoutclose a connection idle this long600 000 ms200 ms
maxLifetimereplace every connection after this age1 800 000 ms500 ms
leakDetectionThresholdwarn when a connection is out this long0 (off)60 ms

The counters under the pool are the ones HikariCP exposes through its MXBean and Micrometer metrics: ActiveConnections, IdleConnections, TotalConnections and ThreadsAwaitingConnection.

A common production configuration

The page uses tiny timeouts so things happen in milliseconds. A typical Spring Boot service on PostgreSQL starts from something like this and then tunes it with the metrics above: Connection Pool Settings in Action plays the incident behind each of these values.

spring:
  datasource:
    url: jdbc:postgresql://db:5432/shop
    hikari:
      pool-name: shop-pool
      maximum-pool-size: 10           # small: bigger is rarely faster
      minimum-idle: 10                # = maximum: a fixed-size pool
      connection-timeout: 3000        # 3 s: fail fast instead of the 30 s default
      max-lifetime: 1500000           # 25 min: below every DB / proxy / firewall limit
      keepalive-time: 120000          # ping idle ones so firewalls keep them
      leak-detection-threshold: 20000 # warn when a connection is held over 20 s
      data-source-properties:
        socketTimeout: 30             # seconds; no thread hangs forever
SettingCommon valueWhy
maximumPoolSize10–20Start near cores × 2 of the database and raise it only if ThreadsAwaitingConnection stays high while the database still has CPU to spare (sizing).
minimumIdle= maximumPoolSizeHikariCP recommends a fixed-size pool: a burst never waits for new connections to open. Set it lower only to save idle connections on a shared database; then idleTimeout matters.
connectionTimeout1–5 sShorter than the HTTP request timeout, so a starved pool returns a clear error instead of piling up threads.
maxLifetimea few minutes under the shortest limitLoad balancers, cloud database proxies, firewalls and MySQL’s wait_timeout can close a connection the pool still holds; the pool should retire it first. The default 30 min is fine when there is no such limit.
keepaliveTime1–5 minMust be shorter than maxLifetime and than any idle cut-off on the network path.
leakDetectionThresholdlonger than your slowest legitimate transactionLogs the stack trace of a borrower that kept a connection too long (leaks). Too short gives false alarms.
driver socketTimeoutabove the longest queryWithout it a query on a dead network connection can block a thread and its pooled connection forever.

Two limits have to fit together across the whole system:

  • Connections: app instances × maximumPoolSize (+ migrations, admin tools, other services) must stay below PostgreSQL's max_connections minus superuser_reserved_connections. 6 instances × 10 = 60 fits in the default 100; autoscaling to 12 instances does not. That is the point where a PgBouncer in front of the database helps.
  • Time: keep the database's statement_timeout and idle_in_transaction_session_timeout set as well, so one slow query or a forgotten transaction cannot hold a pooled connection for minutes.

Fewer queries per request also means each borrow is shorter: an ORM loop with N+1 queries keeps a connection N times longer than one join would.

A pool in the app vs PgBouncer

A pool can also sit outside the application, in front of the database. PgBouncer accepts many client connections and shares a few real server connections among them.

Pool in the app (HikariCP)PgBouncer
Whereinside each application processa separate process near the database
Server connections shared bythe threads of one processall clients of all applications
When a server connection is given backat close()at the end of the session, transaction or statement (pool_mode)
Good fora few long-running app serversmany app instances, serverless functions

Many systems use both. In transaction mode, session state (SET, advisory locks, some prepared statements) does not survive between transactions.

Why PostgreSQL in particular needs a pooler (a process per connection, a hard max_connections) is animated in PostgreSQL Process Architecture.

What the page leaves out

  • TLS, and real timings: a fork and a SCRAM login cost more than shown, and networks are slower than 1 ms.
  • Checking a connection before lending it (HikariCP skips the check if it was used in the last 500 ms) and what happens after a database restart.
  • Connection state that leaks to the next borrower: open transactions, autoCommit, session settings. HikariCP resets the standard ones on return.
  • Prepared-statement caches per connection, several application instances, read replicas, and asynchronous drivers (R2DBC).
  • The order in which HikariCP really hands out connections: it prefers the one this thread used last, and a new connection may go to any waiter.

See also How PostgreSQL Runs a Query for what the backend process does with the query, and TCP Socket for the handshake. The N+1 Query Problem shows how an ORM loop can hold a connection for many small queries.