Back to Connection Pooling
Q3Question 3 of 6

Why is a DB connection pool bounded by the database's max_connections rather than by app load?

Key Concepts
  • The pool must be sized against the database's finite capacity, not against incoming application traffic, because the database is the bottleneck.
  • Each PostgreSQL connection is a separate OS process consuming memory, sort buffers, and CPU scheduling overhead.
  • Throughput peaks at a small multiple of CPU cores and then degrades as more connections add context-switching and lock contention.
  • The HikariCP guidance is pool size of roughly cores times two, and pools in single digits per instance serve very high request rates.
  • If N instances times pool size exceeds `max_connections`, the database rejects connections with a 'too many clients' error.
  • The cascade worsens as surviving connections slow down, pools empty faster, and even the DBA may lose administrative access.
  • The fix is to budget from the database down: take `max_connections`, reserve some for admin, and divide the rest across instances.

Definition

Size the pool to the database's capacity, not to app load: if N instances times pool size exceeds `max_connections`, new connections are refused and the whole system can stall.
  • Database-bounded sizing – A connection pool must be sized against the database's finite capacity, not against incoming application traffic, because the database is the true bottleneck.
  • Per-connection cost – In PostgreSQL, each connection is a separate OS process consuming memory, sort buffers, and scheduling overhead.
  • Optimal pool size – Roughly CPU cores times two per instance; throughput peaks at this point and degrades with more connections.
  • Fleet math – The sum of all instances' pool sizes must stay below max_connections, otherwise the database rejects new connections entirely.

The National Bank of India's central ledger has one hundred phone lines total. If each of ten branches decides 'we are busy, let us each open twenty lines,' that is two hundred lines demanded against one hundred available. The switchboard rejects the overflow and some branches get a busy signal and cannot serve any customer at all. The number of lines a branch opens must be chosen so all branches together never exceed the hundred the head office can handle.

Understanding the Concepts

Why is the pool bounded by the database, not by app load?

A common mistake is reasoning that a thousand concurrent requests need a thousand connections. That is wrong because the database, not the application, is the bottleneck. In PostgreSQL, each connection is backed by a separate operating-system process with its own memory for sort buffers and caches. The server can only truly execute as many queries in parallel as it has CPU cores and disk bandwidth. Beyond that point, adding connections just adds context-switching and lock contention, and throughput actually falls.

JAVA
package com.bank.config;

import com.zaxxer.hikari.HikariConfig;
import com.zaxxer.hikari.HikariDataSource;
import javax.sql.DataSource;

public class CorrectPoolSizing {

    // Database server: 4 CPU cores, SSD
    // Formula: pool_size = (cores * 2) + effective_spindle_count
    // pool_size = (4 * 2) + 0 = 8

    public DataSource createPool() {
        HikariConfig config = new HikariConfig();
        config.setJdbcUrl("jdbc:postgresql://localhost:5432/nbi_ledger");
        config.setUsername("nbi_app");
        config.setPassword("secret");
        config.setMaximumPoolSize(8);  // NOT 100, NOT 1000
        config.setConnectionTimeout(3000);
        return new HikariDataSource(config);
    }
}

The HikariCP guidance is that a pool of around cores times two is often optimal. Pools in single digits per instance routinely serve very high request rates because each connection cycles through many fast queries per second.

What is the max_connections limit?

PostgreSQL enforces a hard ceiling called max_connections. If the pool tries to open more connections than this, the database rejects the connection.

SQL
-- Check the limit
SHOW max_connections;  -- Default: 100

-- Check current usage
SELECT count(*) FROM pg_stat_activity;  -- Currently open connections

-- The budget math:
-- max_connections = 100
-- Reserve for admin (DBA, monitoring, replication): 10
-- Available for apps: 90
-- Number of app instances: 6
-- Per-instance pool size: 90 / 6 = 15
JAVA
package com.bank.config;

import com.zaxxer.hikari.HikariConfig;
import com.zaxxer.hikari.HikariDataSource;
import javax.sql.DataSource;

public class BudgetedPoolConfig {

    // 6 instances × 15 per instance = 90 total
    // max_connections = 100, admin reserve = 10
    // 90 <= 100 - 10 = 90 ✓

    public DataSource createPool() {
        HikariConfig config = new HikariConfig();
        config.setJdbcUrl("jdbc:postgresql://db.nbi.internal:5432/nbi_ledger");
        config.setMaximumPoolSize(15);  // Derived from budget, not from load
        config.setConnectionTimeout(3000);
        return new HikariDataSource(config);
    }
}

What breaks when N instances times pool size exceeds max_connections?

The failure is not graceful degradation. It is a cascade that can take down the entire system.

DIAGRAM
Scenario: Festival sale at National Bank of India
  12 app instances × 50 pool size = 600 connections demanded
  max_connections = 200

Step 1: First 200 connections succeed
  Remaining 400 are rejected:
  FATAL: too many clients already

Step 2: Surviving connections compete for CPU
  Every query slows because the database context-switches
  across 200 processes instead of serving 8 at a time

Step 3: Slow queries hold connections longer
  Pools empty faster → more borrowers block and time out
  Application-wide latency spikes

Step 4: Retry storms make it worse
  Timed-out requests retry, demanding even more connections

Step 5: Admin lockout
  Even the DBA cannot connect to fix the problem
  because max_connections is fully consumed
JAVA
package com.bank.service;

import java.sql.Connection;
import java.sql.SQLException;
import javax.sql.DataSource;

public class ConnectionFailureDemo {

    private final DataSource dataSource;

    public ConnectionFailureDemo(DataSource dataSource) {
        this.dataSource = dataSource;
    }

    public void demonstrateFailure() {
        try (Connection conn = dataSource.getConnection()) {
            // When max_connections is exceeded, this throws:
            // PSQLException: FATAL: too many clients already
            conn.prepareStatement("SELECT 1").executeQuery();
        } catch (SQLException e) {
            System.err.println("Connection failed: " + e.getMessage());
            // Output: Connection failed: FATAL: too many clients already
        }
    }
}

How do you budget pool size from the database down?

The correct approach is to compute the budget from the database down, not from the application load up.

JAVA
package com.bank.config;

public class PoolSizeBudget {

    public static int calculatePoolSize(
            int maxConnections,
            int adminReserve,
            int numberOfInstances) {

        int availableForApps = maxConnections - adminReserve;
        int perInstanceSize = availableForApps / numberOfInstances;

        // Never exceed cores * 2 per instance (diminishing returns)
        int cpuOptimal = Runtime.getRuntime().availableProcessors() * 2;
        return Math.min(perInstanceSize, cpuOptimal);
    }

    public static void main(String[] args) {
        // National Bank of India production setup
        int poolSize = calculatePoolSize(
                200,   // max_connections
                20,    // admin reserve
                12     // app instances
        );
        System.out.println("Per-instance pool size: " + poolSize);
        // Output: Per-instance pool size: 15
        // 12 × 15 = 180 <= 200 - 20 = 180 ✓
    }
}
DIAGRAM
Per-instance pool size: 15
Total: 12 × 15 = 180 connections
Budget: 200 - 20 = 180 available → exact fit

What if the per-instance pool size is too small?

If the budget gives a pool size that seems too small for the load, the answer is not a bigger pool. It is one of these alternatives.

TEXT
Alternatives to a bigger pool:

1. Faster queries
   - A pool of 8 with 1ms queries = 8000 qps
   - A pool of 8 with 100ms queries = 80 qps
   - Fix the query, not the pool

2. Read replicas
   - Route SELECT queries to replicas
   - Primary handles only writes
   - Each replica has its own max_connections

3. External pooler (PgBouncer)
   - Multiplexes 1000 logical connections onto 40 real ones
   - One central choke point for the entire fleet

4. Connection-per-transaction pattern
   - Hold the connection only during the query
   - Not during external HTTP calls or business logic
JAVA
package com.bank.service;

import java.math.BigDecimal;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import javax.sql.DataSource;

public class EfficientConnectionUsage {

    private final DataSource writeDataSource;  // Primary
    private final DataSource readDataSource;   // Replica

    public EfficientConnectionUsage(DataSource writeDs, DataSource readDs) {
        this.writeDataSource = writeDs;
        this.readDataSource = readDs;
    }

    // Read queries go to the replica, saving primary connections
    public BigDecimal getBalance(String accountId) throws SQLException {
        try (Connection conn = readDataSource.getConnection();
             PreparedStatement ps = conn.prepareStatement(
                     "SELECT balance FROM accounts WHERE account_id = ?")) {
            ps.setString(1, accountId);
            try (ResultSet rs = ps.executeQuery()) {
                return rs.next() ? rs.getBigDecimal("balance") : BigDecimal.ZERO;
            }
        }
    }

    // Write queries go to the primary
    public void transfer(String from, String to, BigDecimal amount) throws SQLException {
        try (Connection conn = writeDataSource.getConnection()) {
            conn.setAutoCommit(false);
            // ... debit and credit ...
            conn.commit();
        }
    }
}

What about vertical scaling of max_connections?

Increasing max_connections is possible but has diminishing returns. Each connection in PostgreSQL is a separate process consuming roughly 5 to 10 MB of memory. A server with 16 GB of RAM can only sustain a few hundred connections before memory pressure degrades performance.

SQL
-- Increasing max_connections beyond what the hardware supports:
ALTER SYSTEM SET max_connections = 500;
-- But: 500 connections × 10 MB = 5 GB just for connection overhead
-- Less memory for shared_buffers, work_mem, and caching
-- Performance degrades due to context switching

-- Better approach: keep max_connections moderate
-- and use PgBouncer to multiplex

What are common interview follow-up questions?

  • How does PgBouncer help when many application instances share a database?
  • What is the difference between connection-level and transaction-level pooling?
  • How do you monitor whether your pool size is too small or too large?
  • What happens to in-flight transactions when a connection is evicted from the pool?

What should a senior engineer know?

A senior engineer knows that latency, not connection count, determines throughput. If queries are fast (1 ms), a pool of 8 serves 8000 queries per second. If queries are slow (100 ms), a pool of 100 serves only 1000 queries per second and overwhelms the database with 100 concurrent processes. The fix is always faster queries, not more connections.

DIAGRAM
Senior mental model:
  Throughput = pool_size / avg_query_time

  Pool 8,  query 1ms:   8 / 0.001  = 8,000 qps
  Pool 8,  query 10ms:  8 / 0.010  =   800 qps
  Pool 8,  query 100ms: 8 / 0.100  =    80 qps
  Pool 100, query 100ms: 100 / 0.100 = 1,000 qps
    BUT: 100 connections context-switch and actually get < 1000 qps

  → Fix the query, not the pool size

Visual Overview

DIAGRAM
┌────────────────────────────────────────────────────────────┐
│           Pool Sizing: Database Budget Down               │
├────────────────────────────────────────────────────────────┤
│                                                            │
│  PostgreSQL: max_connections = 200                         │
│  ┌──────────────────────────────────────────────────┐     │
│  │ [Admin: 20] [App Pool: 180]                      │     │
│  └──────────────────────────────────────────────────┘     │
│                                                            │
│  12 App Instances (15 connections each):                   │
│  ┌────┐ ┌────┐ ┌────┐ ┌────┐ ┌────┐ ┌────┐              │
│  │ 15 │ │ 15 │ │ 15 │ │ 15 │ │ 15 │ │ 15 │              │
│  └────┘ └────┘ └────┘ └────┘ └────┘ └────┘              │
│  ┌────┐ ┌────┐ ┌────┐ ┌────┐ ┌────┐ ┌────┐              │
│  │ 15 │ │ 15 │ │ 15 │ │ 15 │ │ 15 │ │ 15 │              │
│  └────┘ └────┘ └────┘ └────┘ └────┘ └────┘              │
│  Total: 12 × 15 = 180 <= 200 - 20 = 180 ✓               │
│                                                            │
│  WRONG: 12 × 50 = 600 > 200                              │
│  ┌────────────────────────────────────────┐               │
│  │ FATAL: too many clients already        │               │
│  │ → cascade → admin lockout → outage     │               │
│  └────────────────────────────────────────┘               │
└────────────────────────────────────────────────────────────┘

Key Takeaway

A connection pool must be sized to the database's capacity, not to the application's incoming load. The intuitive reasoning that a thousand concurrent requests needs a thousand connections is wrong, because the bottleneck is the database, not the application. In PostgreSQL, every connection is a separate OS process with its own memory, and the server can only truly run as many queries in parallel as it has CPU cores. Past that point, more connections just add context switching and lock contention, so throughput actually drops. The practical guidance is a pool of roughly cores times two, and single-digit pools per instance routinely serve very high request rates because each connection cycles through many fast queries per second.

The database enforces a hard ceiling called max_connections. When N instances times pool size exceeds it, connection attempts beyond the limit are rejected outright with a "too many clients" error. The connections that do succeed compete for CPU, so every query slows, which makes each request hold its connection longer, which empties pools faster, which makes more borrowers block and time out. It cascades, and often even the DBA cannot connect to fix it.

The correct method is to budget from the database downward: take max_connections, reserve some for admin and monitoring, divide the rest by the number of instances, and set each pool to that quotient. If the resulting per-instance size seems too small, the answer is faster queries, read replicas, or an external pooler like PgBouncer, not a bigger pool that the database cannot honor.

At the National Bank of India, this lesson was learned during a festival sale when the team scaled the payments service to twelve instances with a pool of fifty each against a database with a limit of two hundred. Six hundred connections demanded, four hundred rejected, total outage. The fix was a per-instance pool of fifteen derived from the budget, and it handled the load because the queries were fast.

Remember one sentence: size the pool to the database's capacity, not to app load, because if a small pool is not enough the answer is faster queries or an external pooler, never a bigger pool the database cannot honor.

---

Share
Use ← → arrow keys to navigate