When an application experiences traffic spikes and database latency rises, the instinctive reaction for many engineering teams is simple:
"Our queries are queuing up. Let's increase
max_connectionson PostgreSQL from 100 to 1,000 and bump our ORM connection pool size."
Within minutes of deploying that change, query latency explodes from 5ms to 4,000ms. Database CPU utilization hits 100%, memory usage spikes, and application pods start crashing with connection timeout errors.
Adding database connections under heavy load does not increase concurrency. In most production systems, it triggers a catastrophic collapse in throughput.
Here is what actually happens inside the Linux kernel, PostgreSQL's process table, and hardware CPU caches when you over-allocate database connections, alongside the exact math to size your pools correctly.
1. The Hardware Reality: CPU Cores Can Only Execute One Thing at a Time
A common misconception among developers is that an active connection represents concurrent query execution.
An 8-core CPU can only execute 8 instructions at any given nanosecond. If your database server has 8 physical cores and receives 500 active queries across 500 open connections, exactly 8 queries are being evaluated by the CPU. The remaining 492 queries are sitting in OS run queues waiting for CPU time slices.
500 Client Connections
│
▼
┌───────────────────────────────────┐
│ OS Thread / Process Queue │
│ (492 Backends Waiting / Blocked) │
└─────────────────┬─────────────────┘
│ Context Switch Storm
▼
[ 8 Physical Cores ]
(Evaluating 8 Queries at a Time)
When the operating system scheduler attempts to juggle hundreds of runnable processes across a handful of physical cores, it enters a state known as context-switching thrashing:
- State Preservation Overhead: For every context switch, the kernel must save CPU register states, stack pointers, and program counters to memory, then restore the state of the incoming process.
- Translation Lookaside Buffer (TLB) Invalidation: Because PostgreSQL uses separate processes rather than lightweight threads, switching between backend processes forces the CPU to switch page directory tables, invalidating the TLB cache.
- L1/L2/L3 Cache Eviction: Query execution engines rely heavily on CPU caches for parse trees, index root pages, and sort buffers. Constant process switching flushes the cache lines, forcing the CPU to fetch data repeatedly from slower system RAM.
At 500+ active connections on an 8-core machine, the CPU spends more clock cycles managing process scheduling and cache misses than executing SQL logic.
2. The PostgreSQL Process-per-Connection Architecture
Unlike MySQL (which assigns a lightweight thread to each connection) or Redis (which uses an asynchronous event loop), PostgreSQL uses a process-per-connection model.
When a client establishes a connection, the PostgreSQL supervisor daemon (postmaster) invokes the fork() system call to spawn an independent OS process:
postgres: user database 10.0.1.25(54322) SELECT
This architectural choice offers immense memory isolation and fault tolerance (a crashing worker cannot corrupt other backends), but it imposes severe per-connection costs.
┌────────────────────────┐
│ Postmaster Daemon │
└───────────┬────────────┘
│ fork()
┌──────────────┼──────────────┐
▼ ▼ ▼
┌─────────────┐┌─────────────┐┌─────────────┐
│ Backend #1 ││ Backend #2 ││ Backend #N │
│ Private RSS ││ Private RSS ││ Private RSS │
│ 5-10 MB ││ 5-10 MB ││ 5-10 MB │
└──────┬──────┘└──────┬──────┘└──────┬──────┘
│ │ │
└──────────────┼──────────────┘
▼
┌───────────────────────────────────────────┐
│ Shared Memory Segment │
│ (shared_buffers, ProcArray, WAL Buffers) │
└───────────────────────────────────────────┘
A. Private Memory (RSS) Footprint
Every PostgreSQL backend process maintains its own private address space:
- Base Process Memory: Between 5 MB and 10 MB of RSS for process stacks, catalog caches, and local memory contexts.
-
work_memDynamic Allocation: Thework_memsetting (default 4 MB) is allocated per sort or hash operation node in a query plan, not per connection. A complex query performing two joins, an aggregation, and anORDER BYcan allocate 4 separatework_memchunks:
$$\text{Query Memory} = 4 \times 4\,\text{MB} = 16\,\text{MB}$$
If 300 active connections execute similar reporting or join queries concurrently:
$$\text{Total Memory Demand} = 300 \times 16\,\text{MB} = 4.8\,\text{GB}$$
This memory is allocated outside shared_buffers. When combined with base RSS, unconstrained connection counts quickly trigger the Linux Out-Of-Memory (OOM) Killer, terminating the PostgreSQL master process.
B. Shared Memory Contention: The ProcArrayLock Bottleneck
To provide Multi-Version Concurrency Control (MVCC) snapshot isolation, PostgreSQL backends must track the status of all active transactions.
This tracking happens in a central shared-memory structure called ProcArray (an array of PGPROC structures). Whenever any transaction begins, commits, or aborts, it must acquire an exclusive or shared lock on ProcArrayLock.
With 20 connections, acquiring ProcArrayLock is instantaneous. With 1,000 connections, hundreds of CPU cores and threads spend thousands of clock cycles spinning on futexes and spinlocks just trying to register transaction snapshots.
3. The Queueing Math: Little's Law
Queueing theory proves why adding connections slows down your database. Little's Law defines the mathematical relationship between concurrency, throughput, and latency in any stable system:
$$L = \lambda \times W$$
Where:
- $L$ = Average number of concurrent requests in the system.
- $\lambda$ = System throughput (arrival rate in queries per second).
- $W$ = Average time spent processing each query (latency in seconds).
Let us apply real numbers. Suppose your API requires your database to process 2,000 queries per second (QPS), and your database indexes are well-tuned such that the average query takes 5 milliseconds (0.005s):
$$L = 2000 \times 0.005 = 10$$
You only need 10 concurrent connections to sustain a throughput of 2,000 QPS.
Incoming: 2,000 QPS ──▶ [ 10 Active DB Connections ] ──▶ Completed in 5ms
What Happens When You Add 200 Connections?
If your database hardware can only execute 10 queries concurrently without CPU contention, opening 200 connections does not increase $\lambda$ (throughput). The physical disk and CPU remain bounded.
Instead, the extra connections increase $W$ (latency) because queries spend time waiting in OS run queues and lock barriers:
$$W = \frac{L}{\lambda} = \frac{200}{2000} = 0.100\,\text{seconds} = 100\,\text{ms}$$
By opening 200 connections to solve a perceived capacity problem, query latency increases by 20x (from 5ms to 100ms) with zero gain in throughput.
4. The Canonical Connection Pool Formula
The PostgreSQL development team and the authors of HikariCP established an empirical formula for optimal database connection pool sizing:
$$\text{Pool Size} = (\text{Core Count} \times 2) + \text{Effective Spindle Count}$$
Let us break down each term:
- $\text{Core Count} \times 2$: When one query briefly stalls waiting for a page fault or synchronous disk I/O, the operating system can seamlessly switch the CPU core to execute a second active query without idle CPU bubbles.
-
$\text{Effective Spindle Count}$: In traditional spinning disks (HDDs), each physical spindle could seek independently. On modern NVMe SSDs where active working sets are cached in RAM or
shared_buffers, effective spindle count is 1 (or approaching 0 for 100% in-memory datasets).
Practical Calculation
For a standard cloud instance with an 8-core CPU running on NVMe storage:
$$\text{Optimal Pool Size} = (8 \times 2) + 1 = 17\,\text{connections}$$
Hardware Specs Formula Calculation Optimal Connections
───────────────────────────────────────────────────────────────────
4 CPU Cores, SSD (4 * 2) + 1 9 Connections
8 CPU Cores, SSD (8 * 2) + 1 17 Connections
16 CPU Cores, SSD (16 * 2) + 1 33 Connections
32 CPU Cores, SSD (32 * 2) + 1 65 Connections
Running benchmarks (such as pgbench) on an 8-core server shows that throughput peaks between 16 and 25 connections. Pushing connections beyond 100 causes throughput to drop steeply while response latency rises exponentially.
5. The Kubernetes Multi-Pod Multiplication Mistake
In containerized microservices architectures, default connection settings cause accidental database overload.
Consider a standard setup:
- 10 Kubernetes application pods running Spring Boot, Node.js, or Go.
- Default ORM / pool config:
maximumPoolSize = 20.
10 Pods x 20 Connections/Pod = 200 Total Connections to PostgreSQL
When traffic spikes and Kubernetes Horizontal Pod Autoscaler (HPA) scales the deployment from 10 pods to 30 pods, the connection count surges to 600 connections. The database crashes precisely when user demand is highest.
[ Pod 1 (20) ]
[ Pod 2 (20) ] ──▶ Total: 200 to 600 Connections ──▶ [ 8-Core Postgres ]
[ Pod N (20) ] (Overloaded & Crashing)
The Correct Formula for Distributed Pods
To prevent pool bloat across multiple application instances, allocate connection quotas proportionally:
$$\text{Pool Per Pod} = \max\left(2, \; \left\lfloor \frac{\text{Target DB Connections}}{\text{Number of Pods}} \right\rfloor\right)$$
If your 16-core database supports a target pool of 34 connections across 10 application pods:
$$\text{Pool Per Pod} = \left\lfloor \frac{34}{10} \right\rfloor = 3\,\text{connections per pod}$$
Each pod needs only 3 to 4 connections if queries are fast and transactions are short.
6. Client Pools vs Middleware Proxies (PgBouncer)
When you have hundreds of microservices, serverless functions (like AWS Lambda), or edge workers, dividing 20 connections across 500 callers is impossible.
This is where connection pooling layers differ:
┌───────────────────────────────────────────────────────────────┐
│ Connection Management │
├───────────────────────────────┬───────────────────────────────┤
│ Client-Side Pooling │ Middleware Proxy Pooling │
│ (HikariCP, pgxpool, Prisma) │ (PgBouncer, Odyssey, Pgpool) │
├───────────────────────────────┼───────────────────────────────┤
│ Operates inside app process │ Standalone proxy server │
│ Cannot coordinate across pods │ Centralized queueing for all │
│ High idle connection overhead │ Ultra-lightweight (2KB / conn)│
│ Best for fixed monorepos │ Mandatory for serverless/K8s │
└───────────────────────────────┴───────────────────────────────┘
Why PgBouncer in Transaction Mode Solves the Problem
PgBouncer acts as a lightweight proxy between your application fleet and PostgreSQL. Built on a single-threaded asynchronous epoll event loop, PgBouncer can maintain 10,000 idle client connections with negligible memory usage (~2 KB per connection).
In Transaction Pooling Mode (pool_mode = transaction):
- A client connects to PgBouncer. PgBouncer accepts the TCP connection immediately.
- No PostgreSQL backend process is assigned while the client is idle (e.g., executing application business logic or external API calls).
- The moment the client issues
BEGINor a standalone SQL query, PgBouncer binds an active PostgreSQL server connection from its small pool of 20 backends. - As soon as the client issues
COMMITorROLLBACK, PgBouncer reclaims the PostgreSQL backend and hands it to another waiting client.
[ 10,000 Client Apps / Lambdas ]
│ (Lightweight TCP, 2 KB RAM each)
▼
[ PgBouncer (pool_mode=transaction) ]
│ (Strictly 20 Server Connections)
▼
[ PostgreSQL (8-Core Server) ]
Transaction Pooling Incompatibilities to Watch Out For
Because transaction pooling reassigns the underlying PostgreSQL connection after every transaction, session-scoped features will not persist across transactions:
-
Session Variables:
SET TIME ZONEorSET search_pathwill affect whichever backend process ran the command, leaking state to subsequent clients. UseSET LOCALwithin transaction blocks instead. -
LISTEN/NOTIFY: Requires a dedicated long-lived connection (pool_mode = session). - Prepared Statements: Historically broken in transaction pooling. Since PgBouncer 1.21+, you can enable protocol-level prepared statement tracking by configuring:
;; pgbouncer.ini
max_prepared_statements = 200
7. Diagnosing Connection Health in Production
To inspect how connections are behaving on your PostgreSQL instance, run this diagnostic query:
SELECT
state,
count(*) AS connection_count,
round(avg(extract(epoch from (now() - state_change))), 2) AS avg_seconds_in_state,
round(max(extract(epoch from (now() - state_change))), 2) AS max_seconds_in_state
FROM pg_stat_activity
WHERE pid <> pg_backend_pid()
GROUP BY state
ORDER BY connection_count DESC;
How to Interpret the Output:
-
idle in transaction(Danger): Application code opened a transaction (BEGIN), executed a query, and stalled waiting for HTTP calls or internal processing before issuingCOMMIT. This holds locks and blocks VACUUM cleanup. -
idle(Waste): Connections are established and sitting unused. Ifidleconnections exceed 80% ofmax_connections, lower your application pool sizes or introduce PgBouncer. -
active(Executing): Ifactiveconnections consistently exceed(CPU Cores * 2), queries are waiting on locks, slow disk scans, or missing indexes.
8. Summary: Senior Engineer Sizing Rules
When designing connection architectures:
-
Keep database pools small: A pool size of
(Cores * 2) + 1maximizes CPU cache efficiency and minimizes scheduling thrashing. - Queue in the application, not the database: Let application threads or connection poolers hold waiting requests in memory rather than forcing PostgreSQL to fork heavy processes.
-
Guard against pod scaling: Ensure
(Pods * Pool Size)never exceeds your database core limit. - Use PgBouncer for high concurrency: If your architecture requires hundreds of client connections (microservices, serverless), place PgBouncer in front of PostgreSQL in transaction pooling mode.
- Keep transactions short: Never perform network I/O, file operations, or heavy computational tasks inside an open database transaction block.
Top comments (0)