Loading content...
You've implemented connection pooling. Your application no longer creates connections per request. But now you face a deceptively difficult question: How many connections should your pool maintain?
This question has derailed more production systems than almost any other configuration decision. Set the pool too small, and requests queue up while idle database capacity goes unused. Set the pool too large, and database performance degrades under the weight of connection overhead, locking contention, and context switching. The sweet spot—where your pool perfectly matches your workload's needs—requires understanding both the theory and the practice of pool sizing.
By the end of this page, you will understand the mathematical principles that govern optimal pool sizing, practical heuristics for different workload types, the difference between minimum and maximum pool sizes, and specific tuning strategies for CPU-bound versus I/O-bound workloads. You'll leave with concrete formulas and decision frameworks.
Connection pool sizing is a classic Goldilocks optimization: too few connections starves your application; too many drowns your database. Understanding both failure modes is essential for finding the right balance.
Too few connections:
Too many connections:
Most developers assume more connections = more throughput. In reality, database throughput typically peaks between 50-200 active connections for most workloads, then declines as connection count increases. PostgreSQL documentation explicitly warns that connection counts above a few hundred degrade performance on typical hardware.
The database perspective:
To understand why more connections hurt performance, consider what happens inside the database:
| Connection Count | CPU Overhead | Memory Usage | Lock Contention | Net Throughput |
|---|---|---|---|---|
| 10 | ~1% | 100MB | Minimal | Baseline |
| 50 | ~3% | 500MB | Low | 4.5x baseline |
| 100 | ~5% | 1GB | Moderate | ~8x baseline |
| 200 | ~10% | 2GB | Significant | ~12x baseline (peak) |
| 500 | ~25% | 5GB | High | ~10x baseline (declining) |
| 1000 | ~50% | 10GB | Severe | ~6x baseline |
| 2000 | ~80% | 20GB | Crippling | ~2x baseline |
Beyond a certain point, adding connections actually reduces total throughput. The database becomes so busy managing connections that it has less capacity for actual query execution.
While pool sizing involves many variables, there's a fundamental formula that provides an excellent starting point. This formula, popularized by PostgreSQL experts and proven in production at scale, focuses on what limits throughput: the database server's ability to execute work.
The core formula:
1234567891011121314
Optimal Pool Size = (core_count * 2) + effective_spindle_count Where: core_count = Number of CPU cores on the database server effective_spindle_count = Number of disk spindles for data access For SSD/NVMe storage: effective_spindle_count ≈ 1 (I/O is rarely the bottleneck) Practical formula for modern SSD-backed databases: Pool Size = (core_count * 2) + 1 Example: 8-core database server with SSD Pool Size = (8 * 2) + 1 = 17 connectionsWhy this formula works:
The formula is based on the observation that database throughput is fundamentally limited by CPU cores (for computation) and disk I/O (for data retrieval). The multiplier of 2 accounts for the fact that queries aren't purely CPU-bound—there are I/O waits, lock waits, and other blocking operations where another connection can use the CPU.
For SSD storage, I/O latency is so low that the "spindle count" term becomes negligible. The formula simplifies to roughly 2 connections per core.
Important caveats:
HikariCP, widely considered the best Java connection pool, explicitly recommends pool sizes of 10-20 for most applications, even on large servers. Their documentation states: 'A pool of 10-20 connections can handle thousands of users making transactions, and adding more connections will not help.'
Most connection pools have two key configuration parameters: minimum (or idle) and maximum pool size. Understanding the distinction and how to configure each is crucial for production systems.
Minimum pool size (minPoolSize / minimumIdle):
The minimum number of connections to maintain even when the application is idle. These connections are pre-established and ready for immediate use.
| Consideration | Low Minimum (0-5) | Higher Minimum (5-15) |
|---|---|---|
| Cold-start latency | First requests pay connection cost | Instant response from startup |
| Resource usage during idle | Minimal database connections when idle | Maintains connections even without traffic |
| Burst handling | May lag during sudden traffic | Ready for immediate demand |
| Connection validation | Fewer keep-alive queries needed | More periodic validation traffic |
| Failover recovery | Must establish fresh connections | Some connections may be pre-established |
Maximum pool size (maxPoolSize / maximumPoolSize):
The absolute maximum connections the pool will ever create. This is your hard limit—requests beyond this capacity will wait (or fail if wait times out).
| Consideration | Conservative Maximum | Generous Maximum |
|---|---|---|
| Database protection | Strong—limits connection count | Weak—may overwhelm database |
| Traffic spike handling | Requests queue or reject | Can absorb larger bursts |
| Memory allocation | Lower peak memory usage | Higher peak memory usage |
| Failure modes | Clean rejections under overload | Gradual degradation, then failure |
| Debugging | Clear 'pool exhausted' errors | Subtle performance degradation |
For most applications: set minimum = maximum. This eliminates connection churn, provides consistent behavior, and simplifies debugging. A pool that grows and shrinks adds complexity without meaningful benefit for typical web applications. Start with a fixed-size pool and only add dynamic sizing if you have specific requirements.
When dynamic pool sizing makes sense:
| Use Case | Recommendation |
|---|---|
| Web application with steady traffic | min = max (fixed pool) |
| Batch jobs with variable load | Lower min, higher max |
| Multi-tenant with varying customers | Consider per-tenant pools |
| Development environment | Small fixed pool (2-5) |
| Serverless (external pool like RDS Proxy) | Pool handles scaling internally |
| Peak traffic 10x normal | Consider min = 1/3 max |
Different workload patterns require different pool sizing strategies. Understanding your workload characteristics is essential for optimal configuration.
CPU-bound workloads:
These workloads spend most time in computation: complex aggregations, analytics queries, CPU-intensive functions.
12345678910111213141516
Characteristics: - Queries are computationally expensive - Low I/O wait time - Database CPU consistently high during load - Examples: Analytics, reporting, complex joins Sizing Approach: Pool Size ≈ core_count + 1 Rationale: - Little benefit from connections beyond CPU count - More connections create scheduling overhead - Better to queue than overwhelm Example: 16-core server Pool Size = 16 + 1 = 17 connectionsI/O-bound workloads:
These workloads spend significant time waiting for disk or network I/O: large table scans, blob retrieval, external service calls from database functions.
12345678910111213141516171819
Characteristics: - Queries involve significant disk access - High I/O wait time - Database CPU relatively low during load - Examples: Table scans, large BLOB operations, external data Sizing Approach: Pool Size ≈ (core_count * 2) + effective_spindle_count For SSD: Pool Size ≈ core_count * 2.5 to 3 Rationale: - More connections can utilize CPU while others wait on I/O - Parallelism helps overlap I/O latency - But still limited by I/O bandwidth Example: 16-core server with SSD Pool Size = 16 * 3 = 48 connections (upper bound)Mixed OLTP workloads:
Typical web application workloads with many short queries—the most common pattern.
123456789101112131415161718
Characteristics: - Many small, fast queries - Short transaction durations - Mix of reads and writes - Examples: Web applications, APIs, e-commerce Sizing Approach: Pool Size ≈ (core_count * 2) + 1 Starting Point: - 8-core server: ~17 connections - 16-core server: ~33 connections - 32-core server: ~65 connections Rationale: - Short transactions mean high connection reuse - Mix of CPU and minimal I/O - 2x multiplier provides buffer for I/O overlap| Workload Type | Formula | 8-Core Server | 16-Core Server |
|---|---|---|---|
| CPU-bound analytics | cores + 1 | 9 | 17 |
| Mixed OLTP | cores × 2 + 1 | 17 | 33 |
| I/O-heavy (HDD) | cores × 2 + spindles | 20-25 | 40-50 |
| I/O-heavy (SSD) | cores × 2.5 | 20 | 40 |
| Long-running reports | cores ÷ 2 | 4 | 8 |
These formulas provide starting points. Always validate with production metrics. Monitor connection wait times, database CPU utilization, and query latency percentiles. If connection wait is high and database CPU is low, increase pool size. If database latency increases with pool size, decrease it.
In real production environments, multiple applications and application instances share the same database. Pool sizing must account for this shared access, or you risk overwhelming the database.
The multiplication problem:
123456789101112131415
Given: - 3 application services - Each service has 10 instances (Kubernetes pods, EC2 instances, etc.) - Each instance configured with maxPoolSize = 20 Total Potential Connections: 3 services × 10 instances × 20 connections = 600 connections Database Capacity: 16-core server, optimal pool ≈ 33 connections Problem: Potential demand (600) >> Optimal capacity (33) Even 50% utilization creates 300+ connections Database performance will severely degradeStrategies for coordination:
Kubernetes HPA or AWS ASG can scale your application instances automatically. If each instance has a pool of 20 connections, scaling from 5 to 50 instances creates 1,000 potential connections. Your pool configuration must account for maximum scale, not just current scale. External poolers like PgBouncer become essential for autoscaled deployments.
Example production allocation:
| Component | Instances | Pool/Instance | Total | Priority |
|---|---|---|---|---|
| API Service | 20 | 3 | 60 | High (user-facing) |
| Background Workers | 10 | 2 | 20 | Medium |
| Report Generator | 2 | 5 | 10 | Low (batch) |
| Admin Dashboard | 2 | 2 | 4 | Low |
| Migrations/Maintenance | 1 | 5 | 5 | Reserved |
| Monitoring | 1 | 1 | 1 | Reserved |
| Total | 100 |
This allocation for a 32-core database (optimal ~65 connections) budgets 100 connections—allowing headroom while staying within performance limits. Using an external pooler like PgBouncer between applications and the database can reduce actual database connections to ~30-40 even during peak load.
Beyond pool size, timeout configuration dramatically affects application behavior under load. Misconfigured timeouts cause either false failures or hung requests.
Critical timeout settings:
| Timeout | Purpose | Recommended Range | Too Low | Too High |
|---|---|---|---|---|
| connectionTimeout | Wait for connection from pool | 1-10 seconds | False failures under load | Requests hang too long |
| idleTimeout | Close idle connections | 5-10 minutes | Excessive connection churn | Hold stale connections |
| maxLifetime | Maximum connection age | 15-30 minutes | Excessive reconnection overhead | Stale connections, DNS issues |
| validationTimeout | Connection health check | 5-10 seconds | Valid connections rejected | Slow detection of failures |
| leakDetectionThreshold | Detect unreturned connections | 30-120 seconds | False positive warnings | Leak not detected timely |
12345678910111213141516171819202122
HikariConfig config = new HikariConfig(); // Pool sizingconfig.setMinimumIdle(10); // Keep at least 10 connections readyconfig.setMaximumPoolSize(20); // Never exceed 20 connections // Timeout configurationconfig.setConnectionTimeout(10000); // 10 seconds to acquire connectionconfig.setIdleTimeout(600000); // 10 minutes before closing idleconfig.setMaxLifetime(1800000); // 30 minutes max connection lifeconfig.setValidationTimeout(5000); // 5 seconds for health checkconfig.setLeakDetectionThreshold(60000); // Warn if connection held > 60s // Connection validationconfig.setConnectionTestQuery("SELECT 1"); // Lightweight health check // Database connectionconfig.setJdbcUrl("jdbc:postgresql://db.example.com:5432/myapp");config.setUsername("appuser");config.setPassword("secret"); HikariDataSource dataSource = new HikariDataSource(config);For user-facing requests, it's often better to fail quickly than hang. A connectionTimeout of 1-3 seconds with proper error handling provides better user experience than a 30-second wait that might still fail. Return an appropriate error message and let the user retry rather than leaving them staring at a spinner.
The maxLifetime trap:
Pay special attention to maxLifetime. Setting it too high causes problems:
A value of 15-30 minutes balances stability with freshness. Always set maxLifetime slightly shorter than any infrastructure timeout (firewall, load balancer, database server's idle_session_timeout).
Formulas provide starting points, but production tuning requires observation. Here's how to systematically tune your pool size based on real metrics.
Key metrics to monitor:
The tuning decision matrix:
| Connection Wait | DB CPU | Queue Length | Diagnosis | Action |
|---|---|---|---|---|
| Low | Low | 0 | Underutilized | Pool size appropriate (or smaller) |
| Low | High | 0 | Well-tuned | Optimal—maintain current size |
| High | Low | High | Pool too small | Increase pool size |
| High | High | High | Pool too large | Decrease pool size (despite wait) |
| Low | Very High | 0 | Query optimization needed | Fix queries, not pool |
| Variable | Variable | Variable | Traffic spikes | Consider external pooler |
When both connection wait AND database CPU are high, the solution is often to reduce pool size, not increase it. The database is overloaded, so adding more concurrent connections makes things worse. Fewer connections mean less contention and better throughput.
1234567891011121314151617181920
-- Current connection count by stateSELECT state, count(*) FROM pg_stat_activity GROUP BY state ORDER BY count DESC; -- Connections per applicationSELECT application_name, count(*) FROM pg_stat_activity GROUP BY application_name ORDER BY count DESC; -- Long-running queries (potential connection hogs)SELECT pid, now() - pg_stat_activity.query_start AS duration, queryFROM pg_stat_activityWHERE (now() - pg_stat_activity.query_start) > interval '5 seconds'AND state = 'active'; -- Connection utilization percentageSELECT (SELECT count(*) FROM pg_stat_activity) as current_connections, current_setting('max_connections')::int as max_connections, ROUND(100.0 * (SELECT count(*) FROM pg_stat_activity) / current_setting('max_connections')::int, 2) as utilization_percent;Let's consolidate pool sizing wisdom into actionable guidelines:
Quick reference for common scenarios:
| Scenario | Recommended Pool Size |
|---|---|
| Single application, 8-core database | 15-20 |
| Single application, 32-core database | 50-70 |
| 10 instances sharing 16-core database | 3-5 per instance (use pgbouncer) |
| Serverless with RDS | Use RDS Proxy |
| Development environment | 2-5 |
What's next:
With pool sizing understood, we need to explore specific connection pool implementations. The next page examines popular connection pooling libraries and their configuration, from application-level pools like HikariCP and SQLAlchemy to external poolers like PgBouncer.
You now understand the principles of connection pool sizing. The key insight: more connections is rarely better. Start with the formula, monitor your metrics, and tune based on observed behavior. Next, we'll examine specific pooling implementations and their configuration options.