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.
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)
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 |
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?
Is the fork really significant?
Does tcp_keepalive affect establishment?
How does this compare with MySQL?
mysqlslap or a scripted loop.Related
- PostgreSQL Server-Side Connection Diagnostics — the parent guide to the server’s view of connections.
- Tuning PostgreSQL max_connections and superuser_reserved_connections — what each connection costs the server in memory.
- Tuning HikariCP maxLifetime and idleTimeout — the lifetime settings this measurement informs.
- Reusing Database Connections Across Lambda Invocations — where establishment cost dominates latency.