Definition
- 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.
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.
-- 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
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.
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
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.
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 ✓
}
}
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.
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
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.
-- 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.
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
┌────────────────────────────────────────────────────────────┐ │ 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.
---