All posts
6 min read

Postgres Connection Pooling Explained: Why 'Too Many Connections' Happens and How to Fix It

"FATAL: sorry, too many clients already" usually appears the day an app gets popular. Why Postgres connections are expensive, how application pools and PgBouncer work, the transaction-mode caveats that break things, and how to size a pool without guessing.

databasesinfrastructurearchitecture

Everything works in development. Then traffic picks up, or you deploy to a serverless platform, and the logs fill with:

FATAL: sorry, too many clients already

or, from a managed provider, a message about a connection limit. The database isn't overloaded — it's simply out of connections. This is one of the most common scaling problems for small apps, and it's almost always solved by connection pooling. Here's how it works.

Why Postgres connections are expensive

Each connection to Postgres is served by its own operating-system process on the database server. That design is robust, but each connection costs memory and some CPU, even when idle. So Postgres caps the total with the max_connections setting — 100 by default — and managed providers set limits based on the size of your plan.

A hundred sounds like plenty for a small app. It goes quickly:

  • Your app runs 4 instances, each with a pool of 20 connections: 80.
  • A background worker with its own pool: 10 more.
  • A migration running during deploy, an admin tool, you connected from your laptop: a few more.
  • A deploy starts new instances before the old ones stop: briefly, double.

The serverless version of the problem

On serverless platforms, each function invocation may run in its own environment, and each may open its own database connection. A traffic spike means hundreds of simultaneous invocations, each wanting a connection — and each connection may be left open by an environment that's kept warm. Serverless and traditional Postgres connections are a famously awkward pairing, and it's usually the first place people meet this error.

Fix 1: pool connections inside your app

A connection pool keeps a fixed set of open connections and lends them out. A request borrows a connection, runs its queries, and returns it; the next request reuses it. Opening a connection is slow (a network round-trip, authentication, process startup), so pooling makes requests faster too.

Almost every database library does this already — pg.Pool in Node, SQLAlchemy's pool in Python, and the pools built into ORMs like Prisma and Drizzle's drivers. What goes wrong:

  • Creating a new pool per request. A classic in AI-generated code: new Pool() inside a request handler, so every request opens a new connection that's never shared. Create the pool once, when the app starts, and reuse it.
  • Leaking connections. Borrowing a connection and not returning it when an error occurs. Use the library's helpers that release automatically.
  • Pools too big for the database. Pool size × instance count must fit under the database's limit, with room to spare.

Fix 2: a pooler in front of the database

When you have many app instances or serverless functions, pools inside each one still add up. The answer is a pooler that sits between your app and the database: PgBouncer is the standard; managed Postgres providers often run one for you and give you a separate "pooled" connection string.

Your app opens hundreds of cheap connections to the pooler. The pooler shares a small number of real database connections among them.

PgBouncer has three modes:

Mode A client holds a real connection… Trade-off
Session for as long as it's connected Fully compatible; saves little
Transaction only during each transaction Big savings; some features break
Statement only for each statement Rarely used; no multi-statement transactions

Transaction mode is what makes pooling effective, and it's what most "pooled" connection strings use.

What breaks in transaction mode

In transaction mode, consecutive transactions from the same client can run on different database connections. Anything that relies on state living on one connection between transactions can misbehave:

  • Session settings — SET search_path, SET timezone, and similar don't carry over. Use SET LOCAL inside a transaction, or set defaults on the database role instead.
  • Prepared statements — historically a common source of errors. Recent PgBouncer versions can track protocol-level prepared statements when configured to, and some drivers have a setting for poolers. Check your driver's docs for "PgBouncer."
  • Advisory locks held across transactions.
  • LISTEN / NOTIFY.
  • Temporary tables used across transactions.

The practical pattern: use the pooled connection for your app's normal queries, and a direct connection for the things that need a real session — migrations especially, plus any listener or long-running maintenance work. Many providers give you both strings for exactly this reason.

How big should the pool be?

Counter-intuitively, smaller than you'd think. Postgres can only do as much work at once as the server's CPU and disk allow; beyond that, more simultaneous connections just queue inside the database and compete for resources. A widely cited starting point (from the HikariCP project's guidance) is roughly twice the number of CPU cores on the database server, plus a little. For a small managed database, that's often 10–20 active connections total — not per instance.

A sensible process:

  1. Find your database's connection limit.
  2. Reserve some for migrations, admin, and monitoring — say 10–20%.
  3. Divide the rest between everything that connects, remembering that deploys can briefly double instance counts.
  4. Load-test, and watch for requests waiting on the pool versus queries slowing down in the database.

Other settings worth knowing

  • Idle timeouts on the pool, so connections from quiet periods are closed.
  • idle_in_transaction_session_timeout on the database — kills sessions that opened a transaction and then went silent, a common way to leak a connection and hold locks.
  • Statement timeouts, so one runaway query can't hold a connection forever.

Diagnosing

To see what's connected right now:

select usename, application_name, state, count(*)
from pg_stat_activity
group by 1, 2, 3
order by count(*) desc;

Lots of idle connections from one application means pools are too big or leaking. Many idle in transaction means code is opening transactions and not closing them. Setting application_name in each app's connection string makes this view far more useful.

The checklist

  • One pool per process, created at startup — never per request
  • Pool size × instances fits under the connection limit, with headroom for deploys
  • A pooler (PgBouncer or your provider's) for serverless or many instances
  • Direct connection for migrations, LISTEN, and session-dependent work
  • Idle, idle-in-transaction, and statement timeouts set
  • application_name set so you can see who's connected

EasySpawn provisions a managed Postgres database for every project, reachable from your app through DATABASE_URL, with daily backups — and runs your app as long-lived processes rather than per-request functions, which keeps connection counts predictable. See how it works or join the waitlist.

Related: Your App Needs Background Jobs · How to Back Up a Postgres Database · Redis: When a Small App Actually Needs It · Database Indexes · Database Transactions Explained

Keep reading