← Back to Logs

How Database Connection Pooling Actually Works

Try the interactive lab for this articleTake the quiz (6 questions · ~6 min)

A lot of application teams talk about a database pool as if it were a polite optimisation layer. Reuse a few sockets, save a little latency, move on. That story is incomplete. In production, a connection pool is usually the mechanism that decides how much concurrency the database is allowed to see, how overload becomes visible to callers, and whether one slow request can quietly pin resources needed by hundreds of unrelated requests.

That matters because a database connection is not cheap state. It is a live session with authentication state, transaction state, memory, kernel bookkeeping, and often a dedicated worker on the server side. PostgreSQL still uses one backend process per connection. MySQL and MariaDB use a thread per active connection. Either way, the server does real work to keep a session alive even while the application is waiting on something else.

Without pooling, every request path has to pay connection setup repeatedly, and the database has to accept a burst pattern defined by the network rather than by its own capacity. With badly tuned pooling, the problem just moves. Instead of handshake churn, you get queues, timeouts, connection storms, pinned sessions, or a database with hundreds of active backends all competing for the same CPU caches and locks.

This article is about the mechanics behind those behaviours. We will stay close to what actual drivers and poolers do: create sessions, keep some warm, lend one session to one caller at a time, block or fail when the pool is empty, and recycle old sessions before the network or server kills them at awkward moments. We will use PostgreSQL examples because the process model makes the costs easy to see, but the same ideas apply to other engines and to managed services that sit in front of them.

A Database Connection Is Server State, Not Just A Socket

If you open a TCP connection to a stateless cache and send one request, the transport may be most of the cost. A relational database session is different. The wire is only the beginning.

A typical PostgreSQL connection setup looks roughly like this:

  1. open a TCP socket
  2. maybe negotiate TLS
  3. exchange startup parameters such as user, database, and client encoding
  4. authenticate with SCRAM, MD5, a certificate, or another configured method
  5. fork or assign a backend process that will own the session
  6. load session defaults such as search_path, time zone, and GUC values
  7. become ready for extended-query or simple-query protocol messages

By the time the application sees "connection acquired", the server has already committed memory, process state, and scheduler attention to that session. The idle connection is not free just because no query is running at this exact millisecond.

The server side cost is easy to underestimate when a database is small. Suppose an API in Berlin opens 400 PostgreSQL sessions because every application worker wants its own generous pool. Even if only 30 of those sessions are actually executing SQL at a time, PostgreSQL still has 400 backends with private memory, file descriptors, transaction bookkeeping, and visibility horizons. Add replication workers, maintenance processes, and ad hoc admin sessions, and max_connections starts looking small for good reason.

The application side has cost too. Opening a new connection for every request means:

  • repeated TLS and authentication work
  • more short-lived sockets in TIME_WAIT
  • more DNS and routing sensitivity during bursts
  • more latency variance because some requests pay setup and some do not
  • more pressure on server connection limits during fan-out spikes

That is why pooling exists. It turns repeated setup into a controlled inventory of warm sessions.

One important subtlety follows from this model: a connection is usually exclusive while a caller holds it. Most application pools do not multiplex unrelated transactions down one PostgreSQL session at the same moment. A JDBC or Node client checks out a connection, sends queries, maybe starts a transaction, and returns the session only when it is done. The session carries mutable state, so lending it to two request handlers at once would be a correctness bug, not a performance feature.

That exclusivity is the first reason pool size matters more than people think. If each checked-out session can serve only one caller at a time, then pool size is a concurrency gate. It is not just a cache of sockets.

A Pool Is A Warm Inventory With Three Limits

A good mental model for a connection pool is not "bag of reusable connections". It is "inventory with admission control".

Every normal pool tracks at least three numbers:

  • how many sessions exist now
  • how many can exist at most
  • how many callers are waiting because all sessions are busy

Many libraries expose this with slightly different names. HikariCP uses minimumIdle, maximumPoolSize, and internal pending-acquire metrics. pg for Node tracks total, idle, and waiting clients. PgBouncer separates client connections from server connections and then applies pool limits per database, user, or route. The surface varies, but the mechanism is stable.

A typical lifecycle looks like this:

request arrives
  -> ask pool for a connection
      -> if idle connection exists, lend it immediately
      -> else if total < max, open a new connection and lend it
      -> else queue the caller or fail after acquire timeout
request uses the connection
request returns the connection
  -> pool marks it idle, resets session state if needed, maybe keeps it warm

Three details in that lifecycle drive most production behaviour.

Warm connections hide setup cost from the fast path

If a pool keeps a few idle sessions already authenticated and ready, the next request can start useful work immediately. That matters for latency. It also matters for burst shape. A burst of 20 requests that lands on 10 warm sessions looks very different from a burst that tries to open 20 fresh sessions at once.

The pool may grow only until the configured ceiling

This ceiling is the real contract with the database. If the application pool says max = 16, then that process can present at most 16 simultaneous database sessions, even if 300 HTTP requests arrive together. The rest must wait or fail. That sounds restrictive until you remember the alternative: letting an upstream traffic spike decide database concurrency directly.

Waiting callers form a queue long before the database itself rejects work

Pool pressure often shows up first in the application, not in the database logs. You see p95 acquire time rising, pending borrowers climbing, and eventually request timeouts. The database may still be perfectly alive. It is simply at the concurrency budget you decided it should see.

That is a healthy design when the budget is sensible. It becomes painful when teams misread the symptom. They see callers waiting on the pool and assume the fix is always "raise max connections". Sometimes it is. Often it is not.

A useful shorthand is that the pool has three operating regions:

Region What the caller experiences What the database experiences
idle capacity available immediate checkout some warm sessions idle
pool filling slightly slower first checkout for newly opened sessions extra login and backend creation work
pool exhausted queueing or fast failure concurrency capped at pool maximum

The third region is not automatically a bug. It is the place where your overload policy becomes visible.

Checkout, Hold, And Return Determine Effective Capacity

Pool size alone does not tell you how many requests a service can push through the database. The more important number is how long each request keeps a connection checked out.

This is where a lot of systems accidentally waste capacity.

Imagine a checkout service in Amsterdam with a pool of 12 connections. A request handler does this:

  1. acquire a connection
  2. BEGIN
  3. read customer state
  4. call a fraud service over HTTP
  5. wait 180 ms for the fraud result
  6. write the order row
  7. COMMIT
  8. return the connection

The SQL itself may take only 25 ms, but the connection was pinned for more than 200 ms because the transaction stayed open while the application waited on another network hop. From the pool's point of view, that request consumed eight times more scarce database time than the query latency alone suggests.

Now compare it with a tighter version:

  1. call the fraud service first
  2. acquire a connection only when ready to touch the database
  3. BEGIN
  4. do the read and write
  5. COMMIT
  6. return the connection

Same business logic, radically different pool pressure.

This is why pool tuning and transaction scoping are inseparable. A service can look "database bound" when the real issue is that connections are being held during:

  • remote HTTP calls
  • cache fetches
  • file IO
  • expensive JSON serialisation
  • application-side loops before COMMIT
  • retries inside one open transaction

A pool cannot tell the difference between "running a hard query" and "request handler forgot to return the connection promptly". Both just look like long hold times.

One-query-at-a-time behaviour matters here too. Even when a driver supports pipelining or async APIs, most transactional code paths still need an exclusive session until the logical unit of work finishes. Prepared statements live in the session. Temporary tables live in the session. Transaction isolation lives in the session. Advisory locks can live in the session. If the application keeps that state alive, the pool has to respect it.

A practical way to observe this is to compare two latencies:

  • SQL execution time on the database
  • connection hold time in the application

If median query time is 18 ms but median checkout duration is 95 ms, the pool is not suffering from SQL alone. Something above the driver is stretching the critical section.

This also explains why leak detection features exist. If a borrower forgets to return a connection, the pool sees permanent capacity loss. In a pool of 10, one leaked connection means 10 percent of concurrency vanished. Three leaked connections on a busy service is an incident, even if the database itself is perfectly healthy.

Pool Size Is A Concurrency Cap, Not A Performance Slider

The most common pooling mistake is treating max pool size like a throughput knob. More connections must mean more performance, right? Only up to the point where the database can usefully run that many tasks in parallel.

Connection pools should usually be sized from concurrency need, not from optimism.

The simplest starting model is Little's Law in its operational form:

concurrency needed ≈ throughput × connection hold time

If a service in Athens needs 180 database-backed requests per second, and each request holds a connection for 30 ms on average, then the steady-state concurrency need is about:

180 × 0.030 = 5.4

So the service needs something like 6 active connections for the average case, plus headroom for variance. Maybe the right pool is 10 or 12. It is probably not 100.

Why not overshoot and be safe? Because active database sessions are not neutral.

On PostgreSQL, more active backends mean more:

  • CPU scheduler contention
  • lock-table traffic
  • shared-buffer churn
  • cache misses from larger working sets
  • context switching between backends that all think they are entitled to run now
  • memory pressure from per-session state and query work areas

On MySQL, thread-per-connection has similar costs in a different shape. Managed proxies add their own layers, but they cannot repeal the underlying CPU and memory math.

That is why a database often has a sweet spot where a moderate number of active sessions keeps all cores busy, and a larger number just increases waiting inside the engine. The pool's job is to keep the application near that useful region instead of flooding the server with runnable work that cannot make forward progress.

A concrete example makes this clearer. Suppose a PostgreSQL primary in Paris can comfortably execute about 24 truly active OLTP transactions at once before lock waits and CPU run-queue length start growing sharply. If six application instances each set maximumPoolSize = 20, the fleet can expose 120 simultaneous sessions to a server that wants roughly 24 active workers. You have not created capacity. You have created a larger queue inside the database, where waits are harder to interpret and often more expensive.

A smaller fleet-wide budget, say 4 instances × 6 connections or 6 instances × 4 connections depending on topology, may deliver better end-to-end latency because the queue stays at the application boundary instead of moving into relation locks, buffer pins, and transaction slots.

This is why serious pool sizing is always fleet sizing. Per-process settings are not enough. If each web worker, background worker, migration job, and cron task owns a pool, their maxima add up. Operators often discover this only after a deploy:

8 API pods × 12 connections   = 96
4 job pods × 8 connections    = 32
1 migration tool × 10         = 10
admin and monitoring reserve  = 12
-----------------------------------
potential total               = 150

If PostgreSQL max_connections is 160, you are already living dangerously, and that still says nothing about whether 150 concurrent backends is a good idea.

A better question than "how big should this pool be?" is "how many concurrent database tasks can the whole system usefully sustain, and which components are allowed to spend that budget?"

Server-Side Reserves Matter As Much As Pool Maxima

Sizing the pool against average concurrency is not enough. You also need slack on the server side for work that is not part of the steady-state request path.

PostgreSQL makes this visible because the limits are explicit. There is max_connections, there are reserved superuser slots, and there are background workers that are not part of your application's pool budget at all. Even before user traffic arrives, the server already has autovacuum workers, WAL sender processes, checkpointer, background writer, logical replication workers, and any extension workers you installed. The application pool is negotiating for what is left, not for the whole process table.

A simple operational rule is that the database should always have breathing room for at least four classes of work:

  1. normal foreground traffic
  2. admin or break-glass access
  3. maintenance such as vacuum, analyse, and replication work
  4. failover or deploy turbulence where several clients reconnect at once

If you size every application pool so that the ordinary fleet can fill every visible slot, you are assuming that incidents never need human access and that maintenance never overlaps with load. Real systems do not honour that assumption.

This is one reason PgBouncer and similar poolers often use separate caps for ordinary capacity and reserve capacity. The pattern looks like this:

default_pool_size = 40
reserve_pool_size = 8
reserve_pool_timeout = 2

The point is not that 40 and 8 are universal numbers. The point is that the system distinguishes between regular operating budget and short-lived emergency spillover. Without that distinction, a burst of reconnects after a network flap can consume the same scarce server sessions that you wanted to keep available for operators, migration tools, or critical write paths.

Memory makes the reserve problem even sharper. A PostgreSQL backend is not work_mem bytes sitting quietly on a shelf. It is a process that can participate in queries whose sort nodes, hash nodes, bitmap operations, and maintenance tasks may each allocate memory under their own rules. work_mem is not multiplied once per connection. It is multiplied per operation that can use it. That means 20 very active backends can be safer than 120 lightly active backends that all wake up together and hit large sorts after a deploy. The pool is supposed to stop that wake-up pattern from becoming an uncontrolled memory event.

The same idea applies to lock manager pressure and replication delay. If too many sessions are allowed to become active at once, they do not only compete for CPU. They also compete for lock table entries, clog each other's snapshots, and generate WAL at a pace the replicas may struggle to apply. A pool sized only from application thread count misses those second-order costs.

There is also a less glamorous operational reason to keep slack: human debugging sessions are part of the system. When production is on fire in Brussels at 02:00, somebody may need psql, a migration rollback, or a session to inspect pg_stat_activity. If the fleet has already consumed every ordinary slot because all pools were configured to the edge, the incident is harder to diagnose purely because the connection budget was treated as something only the application deserved to use.

That is why good pool budgets often look slightly conservative in dashboards. A service may be able to spike to 18 active queries on a quiet afternoon, yet still keep its pool at 12 because the database has to coexist with other services, vacuum, replica catch-up, schema changes, and operator access. The point of the cap is not to prove courage. The point is to preserve useful service under mixed real-world load.

An honest sizing conversation therefore sounds more like capacity planning and less like library tuning:

  • how many active transactions can the primary execute well?
  • how much of that budget belongs to each workload class?
  • how many slots must stay free for maintenance and recovery work?
  • what reconnect storm can we tolerate without starving operators?

Until those questions have answers, maximumPoolSize = 32 or default_pool_size = 50 is just numerology with a different font.

Queueing Decides Whether Overload Becomes Latency Or Failure

Once the pool is full, the next caller has only a few possible outcomes:

  • wait for a free connection
  • fail fast because acquire timeout expires
  • get rejected immediately if the pooler refuses more waiters

That choice defines the failure shape of the application.

If you allow a long wait queue with a generous acquire timeout, the service may preserve a high success rate during a spike, but latency can explode. Callers sit in line behind work that is already slow. Retries from upstream clients then make the burst worse.

If you use a short acquire timeout, the service fails earlier, but it protects the database and prevents long queues from amplifying end-to-end latency.

Neither approach is always right. The important thing is to choose deliberately.

Suppose a ticketing system in Madrid has a pool of 16, each successful transaction holds a connection for 40 ms, and a sale opens at noon. If 200 requests arrive at once, only 16 can start immediately. The rest are waiting for slots released every few tens of milliseconds. A short queue may be fine. But if one downstream dependency slows the transaction to 300 ms, the same queue now lives six to eight times longer. Callers that looked safe under normal service time now exceed their own request deadlines.

This is why pool acquire metrics often lead the rest of the incident. The progression is predictable:

  1. query or transaction hold time increases
  2. active connections reach the pool maximum
  3. pending acquire count rises
  4. acquire latency rises
  5. request latency rises
  6. upstream retries or load balancers add more pressure
  7. the service starts timing out while the database still appears merely busy

A good pool configuration acknowledges that risk with explicit limits. For example:

maximumPoolSize=16
minimumIdle=4
connectionTimeout=250
idleTimeout=600000
maxLifetime=1740000
keepaliveTime=120000

That Hikari-style shape says: keep a few connections warm, cap concurrency at 16, and do not let callers wait more than 250 ms to borrow one. If the service-level objective for the endpoint is 400 ms, that acquire timeout is part of the budget, not an arbitrary number.

A common bad pattern is letting acquire timeout exceed the caller's own request deadline. Then the application waits on the pool almost until the HTTP request is already doomed, only to do a little database work on behalf of a client that has likely disconnected.

Another bad pattern is infinite or very deep waiter queues. That looks kind under light overload and disastrous under heavy overload. If the database is the scarce resource, the application should normally surface scarcity early enough that upstream components can shed load or degrade gracefully.

The deeper lesson is that a pool is a queueing system whether you admit it or not. If you do not decide where the queue belongs, the system will decide for you, and it often chooses the least observable place.

Session State Is Why Reuse Needs Discipline

People sometimes ask why a pool cannot simply hand any half-used connection to any other request instantly. The answer is session state.

A database session is more than a transport channel. Depending on the engine and the code path, it may carry:

  • an open transaction
  • transaction isolation level
  • temporary tables
  • prepared statements
  • session variables or SET values
  • advisory locks
  • role changes such as SET ROLE
  • current schema search path
  • server-side cursors
  • LISTEN subscriptions

If one borrower leaves any of that behind and the next borrower inherits it, correctness breaks in ways that are difficult to debug. That is why pools either require clean borrower behaviour or perform explicit reset work before reusing a session.

In PostgreSQL, a common manual reset step is:

DISCARD ALL;

That clears prepared statements, temporary objects, advisory locks, session configuration, and more. It is thorough. It is also not free. Running it after every transaction would be expensive for many workloads. Some poolers therefore rely on stricter usage rules instead of always doing the heaviest possible reset.

This is where application behaviour and pooler mode become linked.

If the code assumes it can keep session state around across requests, then the pool must preserve session affinity. That is normal for many ORMs and migration tools. But it means the connection is not an anonymous interchangeable worker. It is a stateful lease.

If the code is disciplined enough to keep state inside one short transaction and not depend on session stickiness afterwards, then more aggressive pooling strategies become possible.

Prepared statements make this particularly visible. In session pooling, a prepared statement can live on one server connection and be reused later by the same client session. In transaction pooling, that assumption often breaks because the next transaction may land on a different backend session. The same is true for temp tables and for any logic that says "I set this session variable earlier, so it will still be here later".

That is why connection pooling bugs often look like random application bugs:

  • one request sees the wrong search_path
  • a temp table exists sometimes and not others
  • advisory locks appear to vanish
  • transaction state leaks across code paths
  • a library that assumed sticky prepared statements fails behind a transaction pooler

The pool did not become haunted. The application asked for stateful session semantics while the infrastructure was optimised for stateless reuse.

The simplest survival rule is this: treat session state as radioactive unless you deliberately need it. Keep transactions short, return connections promptly, and assume the cleanest possible contract. That gives the pool room to do useful work.

External Poolers Change The Mapping Between Clients And Server Connections

In-process pools such as HikariCP, Npgsql, or node-postgres live inside the application. External poolers such as PgBouncer, Odyssey, RDS Proxy, and Cloud SQL Auth Proxy variants sit between clients and the database. That changes the system shape.

An external pooler can accept many client connections while maintaining a smaller number of server-side connections to the database. That is valuable in at least three situations:

  • many short-lived app workers, such as serverless or autoscaled jobs
  • many mostly idle client sessions, such as dashboards or BI tools
  • PostgreSQL deployments where backend process count needs tighter control than client count

The key question becomes: what unit is pinned to a server connection?

Pooling mode Server connection pinned for Session features safe across transactions? Connection reduction potential
session pooling entire client session yes, mostly low to medium
transaction pooling one transaction no for sticky session features high
statement pooling one statement only for very constrained workloads very high

PgBouncer documents these modes clearly because the tradeoffs are sharp.

Session pooling

The client gets a backend session and keeps it for the life of the client connection. This is closest to direct database semantics. ORMs usually behave well here. The downside is obvious: idle client sessions still pin scarce server backends.

Transaction pooling

A client keeps its front-end connection to the pooler, but the pooler lends it a server connection only while a transaction is actively running. At COMMIT or ROLLBACK, the server connection goes back to the pool and can serve somebody else.

This is powerful because it separates client concurrency from server concurrency. An API fleet in Warsaw can keep thousands of TCP sessions open to PgBouncer while only dozens of transactions are actually touching PostgreSQL at once.

But the contract is narrower. Anything that assumes session stickiness across transactions becomes unsafe or needs adaptation.

Statement pooling

The pooler returns the server connection after each statement. This gives the strongest reuse and the weakest session semantics. It is useful only for specialised cases.

External poolers also change incident shape. The application may think it has a healthy connection to the pooler while the pooler itself is saturated on server-side connections. That means you need observability on both sides:

  • client-to-pooler checkout and wait time
  • pooler client counts and server counts
  • pooler queue depth
  • actual database backend activity

Without that split view, teams often misread PgBouncer queueing as a mysterious database slowdown.

A minimal PgBouncer configuration for transaction pooling might look like this:

[pgbouncer]
pool_mode = transaction
default_pool_size = 40
reserve_pool_size = 8
reserve_pool_timeout = 2
max_client_conn = 2000
server_idle_timeout = 600
server_lifetime = 3600

Those numbers are not magic. They simply show the control points: how many server connections exist normally, how much surge capacity exists, how long to tolerate waiting, and how many front-end clients are allowed to queue at the edge.

The big idea is that external poolers let you choose a different mapping between application concurrency and database concurrency. That can be transformative, but only if the application respects the narrower semantics that stronger pooling requires.

Failover, Lifetime Rotation, And Serverless Bursts Create New Pooling Problems

Once a pool is working under steady load, operators often stop thinking about the moments when steady load disappears. Those are exactly the moments when pooling details matter most.

Failover and stale connections

A connection that was healthy five seconds ago may now point at a dead primary, a restarted proxy, or a network path that silently reset. Pools therefore need health checks, max lifetime limits, and retry policies that respect transaction safety.

maxLifetime style settings exist partly to avoid synchronized failure. If every connection lives forever, you eventually discover problems only when the application tries to use a very old socket after an infrastructure change. Recycling connections gradually forces the pool to prove that new handshakes still work.

But recycling has to be staggered. If every connection in a 40-slot pool hits lifetime expiry at the same time, you create your own reconnect storm.

Database-side idle killers

Managed services and firewalls often close long-idle connections after some limit. If the pool keeps connections longer than the path really supports, the next borrower pays the surprise. That is why keepalive settings and max lifetime values should be chosen with the network and service limits in mind, not only the client library defaults.

Serverless and cold-start storms

Serverless runtimes make connection management harsher because the number of application instances can spike far faster than the database can accept new sessions. Fifty cold starts that each open a fresh pool of 10 are not a scaling victory. They are a connection storm.

This is why serverless platforms so often recommend an external pooler or proxy. The fleet needs a shared place to absorb front-end volatility and translate it into a smaller, steadier number of server connections.

Read replicas and role changes

Some applications keep separate pools for reads and writes. That is fine, but the failover story becomes more complicated. If a replica is promoted or a writer endpoint changes, the old pool contents may be valid TCP sessions to the wrong logical role. Pools need a way to drain or recreate connections when topology changes, otherwise callers keep talking to yesterday's idea of the database.

Background jobs that are pool bullies

A web API and a background reindex job do not deserve the same concurrency budget. Yet if both use the same database with similarly large pools, the background side can starve user-facing traffic. This is one reason serious systems often allocate separate budgets per workload class, or use a pooler with per-user and per-database caps.

Pooling therefore belongs to topology and failure planning, not just to driver configuration. The settings that look harmless on a laptop become policy during failover, autoscaling, and maintenance.

The Metrics That Actually Tell You Whether The Pool Is Healthy

A healthy pool is not defined by "database CPU is below 60 percent" or "application threads are busy". It is defined by how smoothly borrowers acquire and release scarce sessions under real traffic.

The most useful application-side metrics are:

  • active connections
  • idle connections
  • pending borrowers
  • acquire latency, especially p95 and p99
  • connection hold time
  • connection creation rate
  • timeout count while acquiring

If your pool library exposes only counts and not timings, add wrappers that measure checkout duration and transaction duration. The difference between them is often where the truth lives.

On PostgreSQL, pair those with server-side views such as:

SELECT application_name,
       state,
       count(*)
FROM pg_stat_activity
WHERE usename = 'app_user'
GROUP BY 1, 2
ORDER BY 1, 2;

and, when you need more detail:

SELECT pid,
       now() - xact_start AS xact_age,
       wait_event_type,
       wait_event,
       state,
       query
FROM pg_stat_activity
WHERE usename = 'app_user'
  AND xact_start IS NOT NULL
ORDER BY xact_start;

Those queries answer questions that pool metrics alone cannot:

  • Are sessions active or just idle in transaction?
  • Are long-lived transactions pinning slots?
  • Are backends waiting on locks, IO, or client reads?
  • Is the pool full because PostgreSQL is busy, or because the app is holding connections too long?

For PgBouncer, SHOW POOLS; and SHOW STATS; add the middle layer. They tell you how many client connections exist, how many server connections are active or idle, and whether requests are queueing at the pooler boundary rather than inside PostgreSQL.

A few metric patterns are worth memorising.

Active at max, pending near zero

The pool is full, but callers are not waiting much. This can be fine. Perhaps the configured cap matches workload need closely.

Active at max, pending growing, acquire latency rising

The service is saturating the pool. Either demand exceeded the intended budget, or hold time increased. Check transaction age and lock waits before raising the pool limit.

Idle high, creation rate high

Connections are being churned rather than reused. Lifetime settings, health checks, or network idle killers may be forcing unnecessary reconnects.

Database backends high, CPU low, many sessions idle in transaction

The problem is not lack of CPU. It is that sessions are pinned by application behaviour. Shrinking transaction scope will usually help more than opening more connections.

Pool timeouts with modest database load

Often the app is waiting on something while holding the connection, or one hot table is causing lock contention that serialises work. "Database load" as a single aggregate metric hides both cases.

Good observability also preserves fleet context. If one pod in Lisbon shows active=10, pending=0, that is not interesting by itself. If 30 pods all show the same thing and they share one database, you are looking at 300 potential sessions whether or not the database wants them.

What Connection Pooling Actually Promises

Connection pooling does not promise that every request gets the database instantly.

It does not promise that more connections always mean more throughput.

It does not promise that application code can treat sessions as stateless when it keeps session state around.

It does not promise that the database can absorb every autoscaling event or every retry storm.

What it does promise, when the system is healthy and the design is honest, is this:

  • connection setup cost is amortised instead of paid on every request
  • the database sees a deliberate concurrency budget rather than raw upstream burst shape
  • overload becomes visible at a boundary you can observe and tune
  • application code that returns connections promptly gets predictable low-latency reuse
  • connection lifetime, health checks, and pooler modes can be used to survive real topology changes

That is a much stronger and more useful promise than "fewer handshakes".

A pool is the traffic light in front of scarce database work. If it stays green forever, the junction floods. If it stays red too aggressively, you waste capacity. If it gives different drivers contradictory signals, queues form in the wrong lanes and everyone blames the road.

The engineering job is to place the queue where you can reason about it, keep the critical section around the database narrow, and make the fleet-wide concurrency budget small enough that the database can execute real work instead of managing a crowd.

Once you see pooling in those terms, the usual tuning questions become sharper:

  • How long do we actually hold a connection, not just run SQL?
  • How much concurrent database work can this cluster usefully sustain?
  • Where should callers wait, and for how long?
  • Which workloads deserve reserved budget?
  • Which session features do we truly need?
  • Would an external pooler change the mapping in a way the application can tolerate?

Those questions are the real mechanism. The pool object in the code is just the handle you use to express them.