ArmorDB Logo
ArmorDB
Postgresql Connection Pool Sizing
PostgreSQL Connection Pool Sizing: A Practical Guide for Web Apps
Back to Blog
Deep Dives
June 22, 2026
9 min read

PostgreSQL Connection Pool Sizing: A Practical Guide for Web Apps

Learn how to size PostgreSQL application pools and PgBouncer budgets without exhausting connections or hiding database bottlenecks.

AE
ArmorDB EngineeringArmorDB engineering
PostgreSQLConnection PoolingPgBouncer

Most PostgreSQL connection problems in web applications are not caused by one bad query. They come from a mismatch between how many clients the application can open and how many sessions the database can run well. A service with ten containers, twenty worker threads per container, background jobs, migrations, and preview environments can accidentally ask PostgreSQL for hundreds of connections before traffic is large enough to justify them.

Connection pooling fixes part of the problem, but only when the pool is sized deliberately. A pool that is too small creates unnecessary waiting in the application. A pool that is too large moves the queue into PostgreSQL, where memory, lock waits, and context switching get worse. The useful goal is not "more connections." It is enough active database work to keep the database busy, plus clear backpressure everywhere else.

This deep dive explains how to size PostgreSQL pools for production web apps, how PgBouncer changes the math, and what to measure before increasing limits.

Why connection count is a capacity decision

Every PostgreSQL client connection is a server-side session. PostgreSQL exposes a max_connections setting, reserves some slots for superusers and maintenance, and documents that increasing connection limits can require more shared memory and kernel resources. Raising the number is therefore not a harmless application setting. It changes the amount of database concurrency the system is willing to accept.

The important distinction is sessions versus useful work. A web framework may keep many sessions open because idle workers want a ready connection. PostgreSQL only benefits from connections that are doing productive work, and even then only up to the point where CPU, I/O, locks, or memory become the bottleneck. After that point, extra connections mostly add queueing and overhead.

This is why managed PostgreSQL setups often include PgBouncer. A pooler lets many application clients share a smaller number of PostgreSQL server connections. It can also give applications a stable endpoint while the database keeps a more controlled concurrency level. The pooler does not make heavy queries cheap, and it does not remove locks, but it gives you a better place to queue.

Start with the workload shape

Pool sizing begins with the way the application uses the database, not with the biggest number a plan allows. A request-driven API with short indexed queries needs different settings from a reporting service that runs long aggregations. A background worker that processes jobs in parallel needs a different budget from a migration runner that should usually be alone.

A practical inventory includes each service that can connect to production: web containers, worker containers, scheduled jobs, admin consoles, migration tools, and read replicas if the application has separate read paths. For each service, count maximum process concurrency, per-process pool size, and whether connections are held for a whole request or only around short database operations. Multiplying container count by application pool size is often the moment when teams discover why production is hitting connection limits.

The goal is to choose where waiting should happen. If the web tier accepts 1,000 simultaneous requests but the database can only run 40 useful queries at a time, some layer must queue. It is usually better for the application pool or PgBouncer to queue briefly with clear timeouts than for PostgreSQL to admit hundreds of active sessions and let the whole system slow down unpredictably.

A sizing model that works in practice

A simple starting model is to reserve database connections by purpose. Keep headroom for maintenance and emergency access, give the main application a controlled budget, and keep background jobs from consuming the same slots that user-facing requests need. Then set application pools small enough that the combined maximum cannot exceed the PgBouncer or PostgreSQL budget.

The table below is not a universal formula, but it is a useful design pattern for a small production SaaS application.

Connection budgetTypical useSizing note
Reserved headroomadmin access, monitoring, backups, urgent fixesKeep outside normal app pools so incidents are debuggable
Web requestsshort transactions and user-facing queriesSize for steady concurrency, not total HTTP request concurrency
Background workersqueues, emails, imports, recurring jobsCap separately so batch work cannot starve the app
Migrations and maintenanceDDL, data backfills, one-off scriptsRun with an explicit low pool and scheduled windows
Analytics or reportingslower reads and exportsPrefer separate paths or replicas when queries are long-lived

For an application using PgBouncer in transaction pooling mode, the app may open more client connections to PgBouncer than PgBouncer opens to PostgreSQL. The server-side pool should still be conservative. If each request spends only a small fraction of its time inside a database transaction, multiplexing can absorb bursts without assigning a PostgreSQL backend to every waiting HTTP request.

Without PgBouncer, each application pool connection maps more directly to a PostgreSQL session. In that case, total application pool capacity should be safely below max_connections after subtracting reserved slots and non-application users. If five web containers each have a pool of 20, the application can already open 100 sessions before workers and maintenance are counted.

PgBouncer does not remove application pool sizing

PgBouncer changes where sessions are pooled, but it does not mean every application pool can be unlimited. If the application opens too many client connections to PgBouncer, the pressure moves to the pooler. That can still create timeouts, memory use, and confusing incident symptoms. The clean design is a two-layer budget: application pools protect each process, and PgBouncer protects PostgreSQL.

PgBouncer's pooling mode matters. Session pooling keeps a server connection assigned to a client for the life of the client session, which is compatible with more session features but provides less multiplexing. Transaction pooling releases the server connection after each transaction, which is often the best fit for web applications with short transactions. Statement pooling is more restrictive and is rarely the default choice for application frameworks.

Transaction pooling also requires application awareness. Session-level state, long transactions, advisory patterns, temporary tables, and prepared statement behavior can surprise teams if they assume a client always returns to the same server connection. If your app depends on session state, test it before changing pooling modes. ArmorDB's PgBouncer guide is a useful companion when deciding which mode fits your workload.

Measure before raising limits

When a service reports connection errors, the tempting fix is to raise pool sizes. That can help when the pool is genuinely too small and the database has idle capacity. It can hurt when the database is already saturated or blocked. Before changing numbers, look at what sessions are doing.

PostgreSQL's statistics views are the right starting point. pg_stat_activity shows whether sessions are active, idle, waiting on locks, or idle inside a transaction. Wait events show whether the problem is CPU, I/O, locks, client reads, or something else. If most sessions are idle, raising max_connections is unlikely to improve throughput. If many sessions are active and slow, query plans, indexes, or workload concurrency may be the real issue. If many sessions are waiting on locks, more connections can make the queue longer without increasing useful work.

Application metrics complete the picture. Track pool checkout latency, pool timeouts, request latency, and the number of open client connections per process. PgBouncer metrics such as client wait counts and server pool usage tell you whether the pooler is providing backpressure or constantly saturated. The healthiest systems show short bursts of waiting during traffic spikes and quick recovery when the spike ends.

A practical rollout plan

For a new production app, start smaller than your instinct suggests. Set each web process to a modest pool, cap worker pools separately, and leave visible headroom in PostgreSQL. Load test the main user journeys with realistic request concurrency, then increase the budget only where the database has capacity and application wait time is the bottleneck.

For an existing app with connection incidents, first calculate the theoretical maximum. Count every deployment replica and every process pool. Then compare that number with the database limit and PgBouncer server pool. If the theoretical maximum is far above the database budget, reduce application pools or add PgBouncer before raising max_connections. If the maximum is below the limit but requests still wait, inspect active queries, locks, and transaction duration.

During changes, keep timeouts explicit. Application pool acquisition timeouts should fail requests quickly enough to protect the service. PostgreSQL statement_timeout and idle_in_transaction_session_timeout can prevent abandoned work from holding resources too long. These are guardrails, not substitutes for good sizing, but they turn mysterious slowdowns into errors that can be observed and fixed.

Common mistakes

The first mistake is copying a pool size from a development framework default into production. A default of 10 connections per process may be harmless on one laptop process and dangerous when deployed across dozens of containers. The second mistake is letting background workers use the same database budget as user-facing traffic. Batch jobs are important, but they should not consume every slot during a retry storm.

Another common mistake is using max_connections as the target rather than the ceiling. A database set to allow 200 connections is not saying that 200 busy sessions are optimal. It is defining how many sessions may exist before the server refuses more. Throughput usually has a lower sweet spot, especially for CPU-bound workloads or workloads with lock contention.

Finally, teams sometimes add PgBouncer and leave application pools unchanged. That may solve server connection exhaustion but still creates a huge client-side queue. The better outcome is intentional pressure at every layer: small process pools, a PgBouncer server pool sized for database capacity, and PostgreSQL limits that leave room for maintenance.

Takeaway

Good PostgreSQL pool sizing is a concurrency control problem. Decide how much database work can run at once, reserve space for operations, separate web and worker budgets, and put queues where they are easiest to observe. PgBouncer is valuable because it lets many application clients share fewer PostgreSQL sessions, but it works best when application pools are still deliberate.

If you are running on managed PostgreSQL, use the platform to simplify the operational pieces: predictable connection endpoints, PgBouncer, backups, and clear plan limits. The sizing decision still belongs to the application architecture. Start conservative, measure wait time and active sessions, then expand only when the database has real headroom.

Sources and further reading

Topic

Deep Dives

Updated

Jun 22, 2026

Read time

9 min read

About the author

ArmorDB Engineering writes about PostgreSQL operations, security, and infrastructure decisions for teams building production apps on ArmorDB.