It works fine in staging. A handful of people click around, everything responds, nobody notices anything. Then it goes to production, real traffic shows up, and somewhere in your logs: FATAL: sorry, too many clients already. Nothing about your query logic changed. What changed is how many connections are open at once, and Postgres has a hard ceiling on that number.
What a connection actually costs
In most databases, a connection is cheap, a lightweight thread or an entry in a pool. In Postgres, a connection is a dedicated operating system process. Every client that connects gets its own backend process, with its own memory overhead, typically several megabytes just to exist before it runs a single query.
That process model is part of why Postgres is so stable, an unstable connection can't take down another one's memory space, but it also means connections are not free, and there's a hard limit on how many can exist at once: max_connections, which defaults to 100.
A hundred sounds like a lot until you count how many things are trying to hold one open at the same time.
Why apps blow through it so fast
Connection-per-request, no pooling. Open a new connection at the start of a request, close it at the end. Under low traffic this is invisible. Under real concurrency, you can have hundreds of requests in flight, each holding its own connection, and you hit the ceiling before any single query was slow.
Serverless functions. Each cold start can open its own connection, and serverless platforms scale by spinning up more instances under load, exactly when you can least afford more connections. A traffic spike creates a connection spike in the worst possible direction.
ORMs that don't clean up. A pool that never releases idle connections, or a code path that throws before the finally that returns the connection, leaks connections one request at a time until the ceiling is gone.
Multiple app instances, each with their own pool. If you run ten instances of your app and each opens a pool of twenty connections, that's two hundred connections against a Postgres server that allows one hundred, before a single one of them is even under load.
The pattern that causes it
// a new connection every request, never reused
app.get('/orders', async (req, res) => {
const client = new Client(connectionString);
await client.connect();
const result = await client.query('SELECT * FROM orders');
await client.end();
res.json(result.rows);
});
Fine at ten requests a second. At a few hundred concurrent requests, you're opening and holding that many real Postgres processes at once.
The fix inside your app: an actual pool
// one shared pool, connections are reused, not recreated
const pool = new Pool({ connectionString, max: 10 });
app.get('/orders', async (req, res) => {
const result = await pool.query('SELECT * FROM orders');
res.json(result.rows);
});
A pool holds a fixed number of real connections open and hands them out to whichever request needs one, then takes them back when the query finishes. Requests queue briefly for a free connection instead of each opening a new one. This alone fixes the connection-per-request problem for a single, always-on server.
The fix across many instances: an external pooler
An in-process pool doesn't help once you have many instances, each one still opens its own set of real connections, and they all add up against the same max_connections. The fix here is a pooler that sits between your app and Postgres: PgBouncer is the standard one.
PgBouncer accepts thousands of client connections and multiplexes them onto a small, fixed number of real Postgres connections, handing out an actual connection only for the duration of a transaction, then returning it to the pool the moment the transaction ends. A hundred app instances can each think they have their own pool of client connections to PgBouncer, while Postgres itself only ever sees a few dozen real ones.
Sizing it, and the tradeoff nobody mentions
More pool slots is not automatically better. If Postgres allows 100 connections and you reserve some for admin and migrations, your actual budget might be 80. Ten app instances each opening a pool of 20 is 200, already over budget, regardless of how idle most of those connections are most of the time. Pool size has to be planned against max_connections divided across every instance that connects, not set per instance in isolation.
The tradeoff with PgBouncer specifically: its fastest mode, transaction pooling, breaks anything that depends on session state persisting across queries; prepared statements, session-level settings, advisory locks, LISTEN/NOTIFY. If your app relies on those, you either avoid transaction mode and accept fewer effective connections, or restructure the code that depends on session state.
Takeaway
Postgres connections are real processes with a hard, low ceiling, not a cheap resource you can hand out per request. Pool inside your app so you reuse connections instead of recreating them, and once you're running more than one instance, put an external pooler like PgBouncer in front of Postgres so the number of real connections stays fixed no matter how many app instances or serverless functions are asking for one.
Top comments (0)