Measuring PostgreSQL Connection Establishment Cost

This guide is part of PostgreSQL Server-Side Connection Diagnostics. Pooling exists because opening a PostgreSQL connection is expensive, but “expensive” is rarely quantified — which makes every argument about pool lifetimes, minimum sizes and connection churn a matter of opinion. The cost is measurable, and it is larger than most estimates: the server forks a backend process, both ends negotiate TLS, authentication runs, and the client’s driver issues its startup queries:

$ pgbench -h orders-db.internal -U app -C -c 10 -T 30 orders     # -C: reconnect for every transaction
latency average = 31.412 ms
tps = 318.36 (including connections establishing)
tps = 9241.77 (excluding connections establishing)

Those two throughput figures differ by a factor of 29. The objective is to measure that cost in your environment, attribute it to its parts, and use the number to set lifetimes, minimum pool sizes and churn alerts.

Rapid incident diagnosis

Measure the whole cost first. pgbench with -C reconnects for every transaction, which isolates establishment:

# with connection establishment
pgbench -h orders-db.internal -U app -C -c 10 -T 30 -S orders

# without: the same workload on persistent connections
pgbench -h orders-db.internal -U app -c 10 -T 30 -S orders

The difference in average latency is the establishment cost per connection on that network path, from that client.

Attribute it to its parts. psql with timing, run with and without TLS, separates the two largest terms:

time psql "host=orders-db.internal user=app dbname=orders sslmode=disable" -c "SELECT 1" > /dev/null
time psql "host=orders-db.internal user=app dbname=orders sslmode=require" -c "SELECT 1" > /dev/null
time psql "host=orders-db.internal user=app dbname=orders sslmode=verify-full sslrootcert=/etc/ssl/certs/ca.pem" -c "SELECT 1" > /dev/null

TLS typically accounts for a third to a half of the total on a local network, and more across zones.

Check the server’s own view. pg_stat_database counts sessions, and PostgreSQL logs each connection when asked:

SELECT datname, numbackends, xact_commit, sessions, sessions_abandoned, sessions_fatal, sessions_killed
FROM pg_stat_database WHERE datname = 'orders';
ALTER SYSTEM SET log_connections = on;     -- temporarily; it is verbose
ALTER SYSTEM SET log_disconnections = on;
SELECT pg_reload_conf();

The log lines carry the authentication method and, for disconnections, the session duration — which together show both the cost and the lifetime of each connection.

Look for churn. A high session creation rate with a stable connection count is the shape that wastes the most:

-- sample twice, a minute apart
SELECT sessions FROM pg_stat_database WHERE datname = 'orders';

Divided by 60, that is sessions per second; multiplied by the measured cost, it is the CPU and latency being spent on establishment.

Components of connection establishment A new connection costs a TCP handshake, a TLS handshake, backend process creation and authentication, followed by the driver's startup queries. One connection, about 30 ms total TCP TLS handshake fork auth startup the proportions vary: TLS grows across zones, fork grows on a loaded server measure rather than assume, because the remedy differs for each
Establishment is four costs in sequence, and TLS plus authentication usually dominate on a modern network.

Mathematical sizing or parameter formula

Cost per second of churn. Multiply the rate by the measured cost:

establishment_cost_per_second = sessions_per_second × cost_per_connection

At 40 sessions per second and 30 ms each, 1.2 seconds of connection setup per second of wall clock — spread across clients, but each one also occupying a backend fork on the server.

Latency effect on requests. If a request has to open a connection, it pays the full cost:

p_new_connection ≈ 1 − (pool_hits ÷ total_borrows)
added_p99 ≈ p_new_connection × cost_per_connection

A pool with a 99 % hit rate adds 0.3 ms to the average at a 30 ms cost; a pool with a 50 % hit rate adds 15 ms.

Lifetime settings follow. Recycling connections costs establishment, so the lifetime should be long enough that the cost is negligible and short enough to satisfy rebalancing and network limits:

churn_from_lifetime = pool_size ÷ max_lifetime_seconds     (connections per second per pool)
cost_share = churn_from_lifetime × cost_per_connection

With 16 connections and a 30-minute lifetime: 16 ÷ 1800 = 0.009 connections per second, or 0.03 % of a second of setup per second. At a 60-second lifetime it would be 0.27 connections per second — still small per pool, but multiplied across a fleet it becomes a measurable load on the server.

Minimum pool size. A minimum avoids paying the cost on the request path after idle periods:

min_size ≥ expected concurrent borrows at the daily trough

When the cost justifies a proxy. If the fleet’s session creation rate multiplied by the cost approaches a core’s worth of server time, a pooler that keeps backends warm pays for itself:

proxy_justified when: sessions_per_second × cost_per_connection > 0.5 (seconds per second)
Average latency added by connection establishment At a thirty millisecond establishment cost, a ninety nine percent pool hit rate adds 0.3 milliseconds on average, ninety percent adds 3, and fifty percent adds 15. Average added latency at a 30 ms establishment cost 99 % hits: 0.3 ms 90 % hits: 3 ms 50 % hits: 15 ms hit rate is what pool size, minimum and lifetime together determine
Pool hit rate turns the establishment cost into an average latency. The difference between a 99 percent and a 50 percent hit rate is the difference between negligible and dominant.

Exact remediation & configuration

Measure properly, from where the application runs. A measurement from a developer’s laptop over a VPN says nothing about a pod in the same subnet:

kubectl run pgbench --rm -it --image=postgres:16 --restart=Never -- \
  bash -c 'PGPASSWORD=$PW pgbench -h orders-db.internal -U app -C -c 10 -T 30 -S orders'

Reduce each component where it is worth it.

TLS. Session resumption is not available for PostgreSQL connections in the way it is for HTTP, so each connection pays a full handshake. Where the network is trusted and policy allows, sslmode=require without full verification is cheaper than verify-full, though the security trade is real. Modern cipher suites and ECDSA certificates are measurably faster than large RSA keys.

Fork. The server creates a backend process per connection; on a loaded server that cost rises. Nothing in configuration changes it — which is precisely the argument for a pooler, which keeps backends alive across client connections.

Authentication. SCRAM is more expensive than trust and far cheaper than LDAP or PAM, both of which add a network round trip to an external system. Where LDAP authentication is required, a pooler that authenticates once per backend rather than once per client is a large saving.

Startup queries. Some drivers and ORMs issue several statements on connect — timezone, encoding, search path, version probes. Moving them to role defaults removes them:

ALTER ROLE orders_app SET timezone = 'UTC';
ALTER ROLE orders_app SET search_path = 'public';

Configure pools with the measured number in hand:

spring:
  datasource:
    hikari:
      minimum-idle: 4            # avoids establishment on the request path at the trough
      maximum-pool-size: 16
      max-lifetime: 1800000      # 30 min: churn of 0.009 connections/second
      keepalive-time: 120000
      connection-timeout: 3000

Alert on churn. A rising session rate with a flat connection count is waste:

- alert: DatabaseSessionChurn
  expr: rate(pg_stat_database_sessions{datname="orders"}[10m]) > 5
  for: 30m
  labels: { severity: info }
  annotations:
    summary: "orders: more than 5 new sessions per second — check pool lifetimes and minimums"

Revisit after infrastructure changes. Moving to a different subnet, adding a service mesh, enabling mutual TLS or changing the authentication method all change the measured cost, and with it the justification for the settings above.

Using the number in design decisions

The measurement is most valuable when it settles a recurring argument.

“Should we use a pooler?” Multiply the fleet’s session creation rate by the cost. If it approaches a significant fraction of a CPU-second per second on the database, or if it adds measurable p99 latency, the pooler is justified on this basis alone — before any connection-count argument.

“Why is our serverless latency so variable?” Cold starts pay the full cost, and the proportion of cold starts is the p_new_connection term. The measured cost multiplied by the cold-start rate is the latency the architecture adds, and it is why function platforms benefit from either a module-scope client or a proxy.

“Can we shorten maxLifetime to rebalance faster?” The churn formula gives the cost of doing so. At 16 connections, going from 30 minutes to 5 raises churn from 0.009 to 0.053 connections per second per pool — usually acceptable, and now a decision rather than a guess.

“Is minimumIdle worth the connections it holds?” Compare the establishment cost against the idle connections it reserves across the fleet. For a service with sharp traffic ramps it usually is; for a service with steady traffic and a large fleet it often is not.

Question The number that answers it
Pooler or not sessions_per_second × cost
Lifetime setting pool_size ÷ lifetime × cost
Minimum idle cost against idle connections held
Serverless latency cold_start_rate × cost
TLS policy measured cost with and without verification
One measurement, five decisions The measured establishment cost feeds decisions about pooling, lifetimes, minimum idle connections, serverless architecture and TLS policy. Measured cost 30 ms pooler or not lifetime setting minimum idle serverless design TLS policy take it once per environment, record it, and revisit after network changes
The same measured number answers five separate design questions, which is why it is worth taking once per environment and recording.

Validation & verification

The measurement is repeatable. Run the pgbench -C comparison three times at different times of day; the establishment cost should be stable within a few milliseconds. Large variance points at a loaded server or a noisy network, and both are worth investigating in their own right.

The components add up. The sum of the measured TCP, TLS and authentication times should be close to the total; a large unexplained remainder usually means driver startup queries.

Churn matches the lifetime. With a 30-minute lifetime and a 16-connection pool per replica, the observed session rate should match replicas × 16 ÷ 1800. A much higher rate means connections are being closed for another reason — an idle timeout, a network drop, or a pool that shrinks between bursts.

Removing startup queries helps. After moving settings to role defaults, re-measure; the reduction should be visible in the single-connection timing.

The alert fires on real churn. In staging, set a 60-second lifetime and confirm the churn alert fires, then restore it.

Frequently Asked Questions

Does connection establishment cost the same on a managed service?
The components are the same, but managed services often add a proxy hop and mandatory TLS, which raises it. Measure against the actual endpoint rather than assuming parity with a self-managed instance.
Is the fork really significant?
On a lightly loaded server it is a small part of the total; under load, process creation competes with query execution, and it is one reason a saturated database gets worse when clients reconnect en masse. That interaction is what makes reconnect storms dangerous.
Does tcp_keepalive affect establishment?
No — keepalives affect existing connections. They matter for avoiding unnecessary establishment when a middlebox drops an idle connection, which is a different problem covered in Configuring HikariCP keepaliveTime Behind Firewalls and NAT.
How does this compare with MySQL?
MySQL uses a thread per connection rather than a process, so the server-side creation cost is lower, but TLS and authentication are comparable. The same measurement approach applies with mysqlslap or a scripted loop.