DEV Community

Cover image for Why Increasing Database Connections Makes Queries Slower: Process Overhead, Little’s Law, and Pool Sizing Math
Syed Anzar
Syed Anzar

Posted on

Why Increasing Database Connections Makes Queries Slower: Process Overhead, Little’s Law, and Pool Sizing Math

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_connections on 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)
Enter fullscreen mode Exit fullscreen mode

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:

  1. 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.
  2. 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.
  3. 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
Enter fullscreen mode Exit fullscreen mode

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) │
     └───────────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

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_mem Dynamic Allocation: The work_mem setting (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 an ORDER BY can allocate 4 separate work_mem chunks:

$$\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
Enter fullscreen mode Exit fullscreen mode

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:

  1. $\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.
  2. $\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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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  │
└───────────────────────────────┴───────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

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):

  1. A client connects to PgBouncer. PgBouncer accepts the TCP connection immediately.
  2. No PostgreSQL backend process is assigned while the client is idle (e.g., executing application business logic or external API calls).
  3. The moment the client issues BEGIN or a standalone SQL query, PgBouncer binds an active PostgreSQL server connection from its small pool of 20 backends.
  4. As soon as the client issues COMMIT or ROLLBACK, 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) ]
Enter fullscreen mode Exit fullscreen mode

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 ZONE or SET search_path will affect whichever backend process ran the command, leaking state to subsequent clients. Use SET LOCAL within 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
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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 issuing COMMIT. This holds locks and blocks VACUUM cleanup.
  • idle (Waste): Connections are established and sitting unused. If idle connections exceed 80% of max_connections, lower your application pool sizes or introduce PgBouncer.
  • active (Executing): If active connections 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:

  1. Keep database pools small: A pool size of (Cores * 2) + 1 maximizes CPU cache efficiency and minimizes scheduling thrashing.
  2. 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.
  3. Guard against pod scaling: Ensure (Pods * Pool Size) never exceeds your database core limit.
  4. 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.
  5. Keep transactions short: Never perform network I/O, file operations, or heavy computational tasks inside an open database transaction block.

Top comments (0)