Database Connection Pooling in Production: Why More Connections Can Be Slower
Learn how PostgreSQL connection pooling works, why increasing pool size can hurt performance, how to size pools across multiple application instances, and when to use PgBouncer.
18 min read
Introduction
Imagine your API is getting more traffic.
Everything works normally at first, but as traffic increases, requests start becoming slower.
Then you see an error like:
Timeout while waiting for a database connectionThe first solution that comes to mind might be:
Increase the connection pool.For example:
Before:
Pool size = 20
After:
Pool size = 200That sounds reasonable.
More connections should allow more requests to access the database at the same time.
But in production, something surprising can happen:
More connections
↓
Higher database CPU
↓
More contention
↓
Slower queries
↓
Worse API latencyIncreasing the connection pool can actually make your application slower.
The reason is simple:
A connection pool does not increase the capacity of your database. It only controls how many requests can use the database at the same time.
Understanding this difference is extremely important when running PostgreSQL in production.
In this article, we will understand how connection pools work, how to size them, how to detect saturation and connection leaks, and when tools like PgBouncer become useful.
What Is a Database Connection?
Your application needs a connection before it can send queries to PostgreSQL.
A simplified request looks like this:
User Request
|
↓
Application
|
↓
Database Connection
|
↓
PostgreSQL
|
↓
ResultFor example:
const result = await db.query("SELECT * FROM users WHERE id = $1", [userId]);Behind this simple query, the application needs an available database connection.
Creating a completely new connection for every request would be inefficient.
Imagine doing this:
Request 1
↓
Create DB connection
↓
Run query
↓
Close connection
Request 2
↓
Create DB connection
↓
Run query
↓
Close connectionOpening database connections has overhead.
Instead, applications normally maintain a group of reusable connections.
That group is called a connection pool.
What Is a Connection Pool?
A connection pool keeps several database connections open and reuses them between requests.
For example:
Application
|
↓
Connection Pool
-------------------------
Connection 1
Connection 2
Connection 3
Connection 4
Connection 5
-------------------------
|
↓
PostgreSQLSuppose your pool has:
max connections = 5Five requests can use database connections at the same time.
If a sixth request arrives while all connections are busy:
Request 1 → Connection 1
Request 2 → Connection 2
Request 3 → Connection 3
Request 4 → Connection 4
Request 5 → Connection 5
Request 6 → Waiting...Once one connection becomes available:
Connection 3 released
↓
Request 6 gets Connection 3This is the basic purpose of connection pooling.
It allows your application to reuse connections and control database concurrency.
Why Not Just Create a Huge Pool?
This is where many production problems begin.
Suppose your API starts getting slow because requests are waiting for database connections.
You check your pool:
Pool size: 20
Active: 20
Waiting: 80It is tempting to say:
Let's increase the pool to 200.Now more requests can reach PostgreSQL simultaneously.
But PostgreSQL still has the same:
CPU
Memory
Disk
I/O capacityYou increased the number of workers asking the database to do work.
You did not increase how much work the database can actually handle.
Think of it like a supermarket.
Imagine there are four checkout counters.
Customers
|
↓
-------------------------
Counter 1
Counter 2
Counter 3
Counter 4
-------------------------Allowing 500 customers into the checkout area does not create more checkout capacity.
It just creates more competition and congestion.
Databases behave similarly.
More Connections Can Make PostgreSQL Slower
Every PostgreSQL connection requires resources.
With many active connections, the database may have more:
Memory usage
CPU scheduling
Concurrent queries
Lock contention
Disk activity
Context switchingSuppose one application instance uses:
20 connectionsThat might work perfectly.
Then someone changes it to:
200 connectionsThe database suddenly has much more concurrent work.
Instead of improving throughput, you might see:
Database CPU → 95%
Query latency → increases
API latency → increases
Timeouts → increaseThe important lesson is:
More database connections do not automatically mean more database throughput.
At some point, additional concurrency simply creates more competition for the same resources.
Pool Size Must Include Every Application Instance
Another common mistake is looking at the pool size of only one application server.
Suppose your configuration says:
pool max = 20Twenty connections might sound small.
But your production environment has:
10 application instancesEach instance creates its own pool.
So your real potential connection count is:
10 instances × 20 connections
= 200 database connectionsThe basic calculation is:
total possible connections
=
application instances × pool maxNow imagine your application automatically scales.
Normal traffic:
10 instances × 20
= 200 connectionsDuring heavy traffic:
30 instances × 20
= 600 connectionsYour autoscaling system increased application capacity, but it also tripled the possible database connections.
This can overload PostgreSQL unexpectedly.
Do Not Give Every Connection to the Application
Your API is usually not the only thing connecting to PostgreSQL.
You may also have:
Application servers
Background workers
Database migrations
Monitoring systems
Scheduled jobs
Admin tools
Developers
Maintenance scriptsSo if PostgreSQL allows:
500 connectionsyou should not configure your application fleet to consume all 500.
For example:
PostgreSQL max connections: 500
Application budget: 350
Workers: 50
Monitoring / jobs: 20
Admin / maintenance: 30
Emergency capacity: 50The exact numbers depend on your system.
The important idea is to leave capacity for things other than normal API traffic.
You especially want some connections available when production is having problems.
Otherwise, during an outage, even your admin tools may be unable to connect to the database.
There Are Two Different Places Requests Can Wait
When debugging database performance, it is important to understand that waiting can happen in two places.
Request
|
↓
Connection Pool
|
├── Wait for connection
|
↓
PostgreSQL
|
└── Execute / wait inside databaseThese are different problems.
Waiting for the Connection Pool
Suppose:
Pool size: 20
Active connections: 20
Waiting requests: 100The application cannot immediately get a database connection.
This is called pool acquisition wait.
Possible causes include:
Pool too small
Slow queries
Long transactions
Connection leaks
Traffic spikeNotice that a full pool does not automatically mean the pool is too small.
The database may simply be slow.
Waiting Inside PostgreSQL
The second type of waiting happens after the application already has a connection.
For example:
Application
|
↓
Gets connection immediately
|
↓
Query reaches PostgreSQL
|
↓
Waits 3 secondsThe query might be waiting because of:
High CPU
Slow disk
Missing indexes
Locks
Large queries
Too many concurrent queriesIncreasing the pool in this situation usually makes things worse.
You are sending even more work into an already overloaded database.
Measure Pool Wait and Query Time Separately
You should know how long requests spend:
Waiting for connectionand how long they spend:
Running database workFor example:
Request latency: 800 ms
Pool wait: 600 ms
Query time: 150 ms
Other work: 50 msThis tells you that the query itself is reasonably fast.
The request mostly waits for a connection.
Compare that with:
Request latency: 800 ms
Pool wait: 10 ms
Query time: 740 ms
Other work: 50 msNow increasing the pool is probably not the solution.
The database query is the expensive part.
Useful metrics include:
| Metric | What It Tells You |
|---|---|
| Pool acquisition time | How long requests wait for connections |
| Active connections | Connections currently doing work |
| Idle connections | Connections available for reuse |
| Waiting requests | Requests waiting for a pool slot |
| Query duration | How long queries take |
| Transaction duration | How long transactions stay open |
| Database CPU | Whether PostgreSQL is compute constrained |
| Lock waits | Whether queries are blocking each other |
Looking at these together gives you a much better picture than simply checking the connection count.
Keep Connections for as Little Time as Possible
A database connection is a limited resource.
Your application should acquire one when it needs database work and release it as soon as that work finishes.
For example:
const client = await pool.connect();
try {
await client.query("BEGIN");
await performDatabaseWork(client);
await client.query("COMMIT");
} catch (error) {
await client.query("ROLLBACK");
throw error;
} finally {
client.release();
}The important part is:
finally {
client.release();
}The finally block runs whether the operation succeeds or fails.
That makes sure the connection returns to the pool.
What Is a Connection Leak?
Imagine your pool contains:
20 connectionsA request takes one connection.
But because of a bug, the application never releases it.
Now:
Available connections = 19Another request leaks one.
Available connections = 18Eventually:
Available connections = 0Now every new request waits.
Request
|
↓
Connection Pool
|
↓
No available connection
|
↓
Wait...
|
↓
TimeoutThis is a connection leak.
It can be difficult to notice because the application may work normally for hours before enough connections have leaked to cause an outage.
Always make sure connections are released even when errors occur.
Do Not Hold Connections During Slow External Work
Consider this code:
const client = await pool.connect();
try {
await client.query("BEGIN");
await client.query("UPDATE orders SET status = 'processing' WHERE id = $1", [
orderId,
]);
await callExternalPaymentAPI();
await client.query("COMMIT");
} finally {
client.release();
}There is a problem.
The application holds the database connection while waiting for:
External Payment APISuppose that API takes:
3 secondsYour connection is occupied for those three seconds even though PostgreSQL is doing nothing.
With enough requests:
20 connections
↓
20 requests waiting on external APIs
↓
Pool completely occupied
↓
New requests cannot access PostgreSQLAs a general rule:
Do not hold a database connection or transaction while waiting for unrelated slow work.
Keep transactions focused on database operations and keep them short.
Long Transactions Are Dangerous
A connection being idle does not always mean it is harmless.
One particularly important PostgreSQL state is:
idle in transactionThis means a transaction was opened but has not been completed.
For example:
BEGIN;
UPDATE orders
SET status = 'processing'
WHERE id = 123;
-- application waits here for a long timeThe transaction is still open.
Long transactions can hold locks and interfere with PostgreSQL's normal cleanup behavior.
So monitoring should include:
Transaction agenot only:
Query execution timePool Timeouts Protect Your Application
Suppose your database can safely process a limited amount of concurrent work.
Traffic suddenly increases.
Without limits, requests can continue building up:
Database overloaded
↓
100 waiting requests
↓
500 waiting requests
↓
2,000 waiting requests
↓
Memory increases
↓
Application becomes unhealthyInstead, use bounded waiting.
For example:
Pool acquisition timeout: 500 ms
Statement timeout: 2 seconds
Request timeout: 3 secondsThe exact numbers depend on your application.
The idea is that requests should not wait forever.
A timeout allows the system to say:
The database cannot handle more work right now.instead of building an unlimited queue.
Timeouts should also fit into your overall request deadline. The retry-storm guide explains why retries, timeouts, and downstream capacity need one coordinated budget.
What Is PgBouncer?
As your system grows, you may have many application instances.
For example:
20 API instances
10 workers
5 scheduled-job processesEach process may maintain its own connection pool.
This can create hundreds or thousands of client connections.
PgBouncer sits between your applications and PostgreSQL.
Applications
|
↓
Application Connection Pools
|
↓
PgBouncer
|
↓
PostgreSQLYour applications can connect to PgBouncer while PgBouncer manages a smaller number of actual PostgreSQL connections.
Conceptually:
500 application connections
↓
PgBouncer
↓
100 PostgreSQL connectionsThis can be very useful when you have many application processes or frequently changing instances.
PgBouncer Does Not Make PostgreSQL Unlimited
PgBouncer solves an important connection-management problem.
But it does not magically increase database CPU or query capacity.
Think about it like this:
Application
|
↓
Application Pool
|
↓
PgBouncer Queue
|
↓
PostgreSQLIf PostgreSQL can only process a certain amount of work efficiently, PgBouncer cannot change that.
It helps manage and reuse connections more efficiently.
You still need reasonable limits.
Avoid configuring:
Application pool = huge
PgBouncer pool = huge
PostgreSQL connections = hugeand expecting the performance problem to disappear.
You have simply moved the queue.
PgBouncer Transaction Pooling
One common PgBouncer mode is transaction pooling.
A PostgreSQL server connection is assigned while a transaction is running.
When the transaction finishes:
BEGIN
↓
Queries
↓
COMMIT
↓
Connection returnedthe server connection can be reused by another client.
This makes connection usage efficient.
However, your application should not assume that it owns the same PostgreSQL session forever.
Some session-level features therefore require extra care, including things such as:
Temporary tables
Session variables
Some prepared statement setups
Session-level advisory locksBefore enabling transaction pooling, verify that the database features and driver behavior used by your application are compatible with it.
Autoscaling Can Create a Connection Storm
Suppose your normal production environment has:
5 application instances
Pool size = 20Maximum potential connections:
5 × 20 = 100Traffic suddenly increases and Kubernetes or another platform scales your application to:
30 instancesNow:
30 × 20 = 600 connectionsYour application scaling system just created hundreds of additional potential database connections.
This is why database capacity must be considered when configuring autoscaling.
Scaling the API layer is easy.
Scaling the database is usually harder.
Serverless Applications Need Extra Care
Serverless platforms can make connection management even more challenging.
Imagine hundreds of functions starting at approximately the same time.
If every function creates:
10 database connectionsthen:
200 functions × 10
= 2,000 possible connectionsThe database can become overloaded even if each individual function seems reasonable.
For serverless environments, you may use:
Provider connection proxy
PgBouncer
Database-aware serverless driversdepending on your database provider and deployment environment.
Where supported, reuse connections across warm function invocations rather than opening unnecessary new connections for every request.
How to Diagnose Connection Saturation
PostgreSQL exposes connection information through:
pg_stat_activityA simple query is:
SELECT state, COUNT(*)
FROM pg_stat_activity
GROUP BY state;This helps show how database sessions are being used.
You might see states such as:
active
idle
idle in transactionBut connection count alone is not enough.
Also investigate:
Long-running queries
Long transactions
Lock waits
Database CPU
Query frequency
Pool acquisition time
Waiting requestsFor example, imagine your pool suddenly becomes saturated.
It is easy to conclude:
We need more connections.But perhaps one important query changed from:
20 msto:
2 secondsConnections now remain occupied much longer.
The pool fills up as a result.
The real problem is not:
Pool too smallIt is:
Query became slowIn that situation, increasing the pool may hide the problem temporarily while increasing pressure on PostgreSQL.
The PostgreSQL query-plan guide explains how to investigate slow queries using EXPLAIN ANALYZE.
How Should You Choose a Pool Size?
There is no universal number such as:
pool size = 100that works for every application.
Pool size depends on things like:
Database CPU
Available memory
Query speed
Transaction duration
Application instance count
Background workers
Traffic patterns
Database workloadStart with a conservative number.
Then load-test the system.
For example:
Test 1
10 app instances
10 connections each
Total = 100Measure:
Throughput
P95 latency
P99 latency
Database CPU
Pool wait time
Query latency
Lock waitsThen test another configuration:
Test 2
10 app instances
15 connections each
Total = 150Compare the results.
You may eventually reach a point where:
Connections ↑
Throughput stays similar
Latency ↑
Database CPU ↑At that point, adding connections is no longer helping.
Your goal is not:
Maximum number of connectionsYour goal is:
Enough concurrency to keep the database productive
without overwhelming it.A Practical Example
Suppose you have:
PostgreSQL max connections: 300
Application instances: 10
Workers: 5You decide to reserve:
50 connectionsfor:
Administration
Monitoring
Migrations
Maintenance
Emergency accessThat leaves:
250 connectionsBut that does not mean every application should immediately consume all 250.
You might begin with:
API:
10 instances × 15
= 150 connections
Workers:
5 workers × 10
= 50 connections
Reserved:
50 connectionsTotal:
150 + 50 + 50
= 250Then load-test and monitor the database.
The important part is that pool sizing becomes a capacity decision for the entire system, rather than a random configuration value inside one application.
Common Mistakes
Increasing the Pool Whenever Requests Wait
Seeing requests waiting for connections does not automatically mean:
Increase pool sizeFirst ask:
Why are connections busy?Maybe:
A query became slow
Transactions became longer
Connections are leaking
Database CPU is saturated
Traffic suddenly increasedFix the underlying problem first.
Ignoring Application Instance Count
This configuration:
pool max = 30looks small.
But:
40 instances × 30
= 1,200 possible connectionsAlways calculate connection capacity across the entire fleet.
Holding Connections During Network Calls
Avoid:
Open transaction
↓
Update database
↓
Call external API
↓
Wait 5 seconds
↓
Commit transactionKeep transactions short whenever possible.
Using Huge Pools With PgBouncer
PgBouncer helps manage connections.
It does not remove database capacity limits.
Use deliberate limits at:
Application pool
PgBouncer
PostgreSQLLooking Only at Connection Count
A database with:
100 connectionsmight be healthy.
Another database with:
30 connectionsmight be overloaded.
Connection count has meaning only when combined with:
CPU
Query latency
Lock waits
Transaction duration
Pool waiting
ThroughputProduction Checklist
Before changing your connection pool, check:
- Calculate connections across all application instances.
- Include workers and background jobs.
- Reserve connections for administration and maintenance.
- Measure pool acquisition time.
- Measure query execution time separately.
- Monitor active and idle connections.
- Monitor long-running transactions.
- Release connections in
finally. - Keep transactions short.
- Avoid external network calls while holding connections.
- Configure pool acquisition timeouts.
- Configure sensible statement timeouts.
- Check for slow queries before increasing the pool.
- Test PgBouncer compatibility before using transaction pooling.
- Consider database connections when configuring autoscaling.
- Load-test PostgreSQL, not only your API servers.
Final Architecture Thinking
Think about connection pooling like this:
Incoming Requests
|
↓
Application
|
↓
Connection Pool
|
↓
Controlled Database Concurrency
|
↓
PostgreSQLThe pool protects the database from receiving unlimited concurrent work.
It should not become:
Incoming Requests
|
↓
Huge Connection Pool
|
↓
Thousands of Concurrent Queries
|
↓
Overloaded PostgreSQLWhen your system grows further, the architecture may become:
Application Instances
|
↓
Application Pools
|
↓
PgBouncer
|
↓
Controlled PostgreSQL Connections
|
↓
PostgreSQLThe important word is:
ControlledMore concurrency is useful only while the database has capacity to process it.
Conclusion
Database connection pooling sounds simple:
Keep connections open
↓
Reuse them
↓
Avoid connection setup overheadBut in production, pool sizing becomes a capacity problem.
Increasing:
20 connections
↓
200 connectionsdoes not make PostgreSQL ten times faster.
It may actually make the database slower by increasing concurrent work, CPU pressure, lock contention, memory usage, and I/O.
Instead, think about the entire system:
How many application instances exist?
How many workers exist?
How many connections can each process create?
How long are connections held?
How long do queries take?
How long do transactions stay open?
How much concurrency can PostgreSQL actually handle?Then size the pool based on measurements.
Remember:
A connection pool does not create database capacity. It controls access to database capacity.
A good production pool keeps PostgreSQL busy without overwhelming it, makes overload visible through bounded queueing and timeouts, and leaves enough capacity for important operational work.
References
Related
Written by
Faisal
Software engineer writing about backend systems, Node.js, system design, scalable applications, and modern web and mobile development.