Connections & Pooling
// the problem
Open a connection to Postgres and it does something surprising: it forks a whole operating-system process — a dedicated backend — just for you. That process is powerful (it's where your queries run) but it isn't free, and there's a hard cap on how many can exist. A serverless app that opens a fresh connection per request will hit that wall fast. Understanding this — and the pooler that fixes it — is one of the most practical things you can know about running Postgres at scale.
Below, clients ask to run transactions against a server with a limited number of backends. Flip the pooler on and off and watch what happens to the overflow.
server backends (processes)
clients
without a pooler, every client needs its own backend — extras are rejected
With the pooler off, every client needs its own backend; once they're
exhausted, extra clients are simply rejected (FATAL: sorry, too many clients already). Turn the pooler on and those same clients queue for a small,
shared set of backends — nobody is rejected, they just take turns.
One process per connection
Postgres's postmaster forks a backend process for each connection. That
process holds its own memory — catalog caches, prepared statements, per-backend
buffers — so even an idle connection costs on the order of a few megabytes.
On top of that baseline, every sort or hash a query runs can allocate up to
work_mem while it runs (and a complex query can run several at once). The
total number of backends is capped by max_connections:
Because each backend can allocate work_mem for every sort or hash in a query,
raising max_connections into the thousands is a memory time-bomb. Do the
arithmetic — even one sort per connection at the cap:
That's the ceiling if every connection runs one sort. A real query runs
several sorts and hashes at once, so multiply again: 100 connections × 4 MB × 5
operations is 2 GB — from work_mem alone, on top of shared_buffers and each
backend's own baseline. Push max_connections into the thousands and one traffic
spike can OOM a box with plenty of idle CPU. The healthy number of active
backends is small — a few per CPU core; everything past that just context-switches
and contends.
// why it matters · idle connections aren't harmless
An idle connection still holds its process and memory. Worse, an idle connection in a transaction pins the cleanup horizon (from the VACUUM lesson) and blocks vacuuming database-wide. “Thousands of mostly-idle connections” is the classic way a busy app melts a database that has plenty of CPU to spare.
The fix: a connection pooler
A pooler sits between your app and Postgres and keeps a small set of real
backends open, lending them out to many client connections. It solves two
problems at once. The obvious one: many clients share a few backends, so you never
hit max_connections. The subtler one: opening a backend isn't free — each
new connection costs an OS fork, authentication, and warming that backend's
catalog caches, roughly a millisecond or two plus a few MB every time. An app
that connects, runs one query, and disconnects on every request (the classic
serverless pattern) pays that toll constantly. A pooler keeps backends warm and
reused, so clients skip the setup entirely.
The most impactful mode is transaction pooling (PgBouncer's transaction
mode, and what Supabase's Supavisor does): a client is assigned a backend
only for the duration of a transaction, then it returns to the pool. Since
most connections are idle most of the time, a few dozen backends can serve
thousands of clients.
- Session pooling — a backend is tied to a client for its whole session (simple, but doesn't multiplex much).
- Transaction pooling — a backend is borrowed per transaction (huge multiplexing; the default choice at scale).
- Statement pooling — per statement (most aggressive, most restrictions).
// gotcha · transaction pooling breaks session state
Because a client doesn't keep the same backend between transactions,
anything that lives in a session — SET parameters, session-level advisory
locks, LISTEN/NOTIFY, some prepared statements, WITH HOLD cursors — won't
behave as expected under transaction pooling. Apps designed for a pooler keep
their transactions self-contained and avoid relying on cross-transaction session
state.
Who's connected? Reading pg_stat_activity
pg_stat_activity is your window into every backend, and its state column is
the one you'll read most:
active— running a query right now.idle— connected but doing nothing. Harmless beyond the memory it holds.idle in transaction— inside aBEGIN, waiting on the application, not the database. This is the dangerous one: it pins the VACUUM cleanup horizon and can hold locks while doing no work (the VACUUM and Locks lessons).idle in transaction (aborted)— the same, but the transaction already errored and is just sitting there until someone sendsROLLBACK.
Add wait_event_type / wait_event and you can see what an active backend is
blocked on — a lock, an I/O, the client. The classic firefight is WHERE state = 'idle in transaction' ORDER BY xact_start to find the forgotten transaction
throttling the whole database.
Your turn
What is this server's hard limit on the number of connections?
Count how many backends are currently connected to the server.
// what you now understand
- 01Postgres forks one OS process (a backend) per connection; each holds real memory even when idle, capped by max_connections.
- 02High max_connections is a memory time-bomb: the work_mem ceiling is max_connections × work_mem × sorts-per-query (100 × 4 MB × 5 ≈ 2 GB). Keep active backends to a few per CPU core; the rest just context-switch.
- 03Idle connections waste memory; idle-in-transaction ones pin the VACUUM horizon and can hold locks while doing nothing — the classic bloat/blocking bomb.
- 04A pooler (PgBouncer / Supavisor) solves two things: sharing a few backends under max_connections, AND skipping the per-connection fork + auth + cache-warmup cost — vital for serverless connect-per-request apps.
- 05Transaction pooling borrows a backend only per transaction (max multiplexing) but breaks cross-transaction session state (SET, session advisory locks, LISTEN/NOTIFY).
- 06pg_stat_activity state = active / idle / idle-in-transaction / (aborted); wait_event shows what an active backend is blocked on. Hunt idle-in-transaction by xact_start.
// self-test
A serverless app opens a new Postgres connection on every request and starts failing with 'too many clients already' under load. Best fix?
// self-test
Why keep the number of active backends close to a small multiple of the CPU core count?
// go deeper
- Connections and Authentication — max_connections and related settings
- Resource Consumption (work_mem, shared_buffers) — per-connection vs shared memory
- PgBouncer — the classic lightweight pooler and its pool modes
- pg_stat_activity — inspecting live backends