Headline: A database connection pool in a serverless deployment is per-instance, not per-application. The real ceiling is concurrent instances multiplied by pool size, which is why the fix is almost never a bigger database — it is a connection pooler in transaction mode plus a driver configured to survive one.
I hit FATAL: sorry, too many clients already on a Next.js app whose traffic would not have troubled a single Postgres box in 2005. The database was idle. The application code was fine. What was not fine was my mental model: I had configured one pool with a sensible max and assumed that number described the deployment. It described one function instance.
Key takeaways
- A connection pool is a cache of open TCP connections living inside one Node.js process. Total connections opened by a serverless app are roughly concurrent instances multiplied by each pool's
max, not themaxyou configured. - PostgreSQL ships with
max_connections = 100by default and reserves three for superusers, and every connection is a separate backend process with its own memory. - PgBouncer in
transactionmode multiplexes many clients onto few server connections, but it discards session state: prepared statements,LISTEN/NOTIFY, session-levelSET, and advisory locks held between transactions all break. - Drizzle with
postgres.jsneedsprepare: falsebehind a transaction-mode pooler; Prisma needs?pgbouncer=trueplus a separatedirectUrlfor migrations. - Vercel Fluid Compute reuses one instance across concurrent requests, so
max: 1now serializes handlers instead of protecting the database.
Why does my serverless app run out of Postgres connections?
Because each function instance runs its own Node.js process holding its own pool, so the connections you actually open are concurrent instances multiplied by each pool's max. Nothing about a pool is shared between processes. Ten instances configured with max: 10 is one hundred connections, not ten.
That product collides with a hard server-side limit. PostgreSQL defaults to max_connections = 100, holds three back for superuser access, and spawns a separate backend process per connection. The failure is load-shaped: a traffic spike opens more instances, each opens its own pool, and Postgres answers with FATAL: sorry, too many clients already while database CPU stays flat.
What did Fluid Compute change about connection pooling?
Fluid Compute reuses a single function instance across concurrent requests, so one module-scope pool is shared by several in-flight requests inside the same process. The classic advice — set max: 1, because an instance only ever handles one request — is actively harmful under that model: a pool of one serializes concurrent handlers behind a single connection.
// lib/db.ts
import { Pool } from 'pg';
// Module scope: created once per instance, reused by every request
// that instance serves, including concurrent ones under Fluid Compute.
export const pool = new Pool({
connectionString: process.env.DATABASE_URL,
max: 5, // per instance, not per deployment
idleTimeoutMillis: 10_000, // hand connections back to the pooler
connectionTimeoutMillis: 5_000,
});
Which PgBouncer pooling mode should I use?
Transaction mode, for ordinary application traffic. PgBouncer is a lightweight proxy that multiplexes many client connections onto a small set of real server connections, and pool_mode decides how long a client keeps one of them.
| Mode | Server connection held for | What it breaks | Use it for |
|---|---|---|---|
session |
The whole client session | Nothing | Migrations, LISTEN/NOTIFY, long-lived workers |
transaction |
One transaction | Prepared statements, session SET, advisory locks outside a transaction, WITH HOLD cursors |
Serverless application traffic |
statement |
One statement | Everything above, plus multi-statement transactions | Rare; sharded setups without transactions |
Supabase now fronts Postgres with Supavisor rather than PgBouncer, and Neon and Amazon RDS Proxy ship their own implementations, but the mode semantics are identical everywhere.
Why did my prepared statements break behind the pooler?
Because a protocol-level prepared statement is session state on one server connection, and transaction mode may hand you a different connection for the next transaction. The symptom is a pair of errors alternating under load: prepared statement "s1" already exists and prepared statement "s1" does not exist.
PgBouncer 1.21 and later can track prepared statements in transaction mode when max_prepared_statements is above zero — confirm your provider enables it before depending on it. Otherwise disable them in the driver. node-postgres only uses named prepared statements when you explicitly name a query; postgres.js prepares by default and needs prepare: false; Prisma needs ?pgbouncer=true.
// postgres.js + Drizzle behind a transaction-mode pooler
import postgres from 'postgres';
import { drizzle } from 'drizzle-orm/postgres-js';
const client = postgres(process.env.DATABASE_URL!, {
prepare: false, // named statements do not survive transaction mode
max: 5,
});
export const db = drizzle(client);
How do I stop dev HMR from opening a new pool on every save?
Cache the pool on globalThis in development. Next.js hot module replacement re-evaluates changed modules, so new Pool() at module scope runs again on every save while the previous pool keeps its sockets open. Twenty saves is twenty live pools, and eventually the local database refuses connections.
// lib/db.ts
import { Pool } from 'pg';
const globalForDb = globalThis as unknown as { pool?: Pool };
export const pool =
globalForDb.pool ??
new Pool({ connectionString: process.env.DATABASE_URL, max: 5 });
if (process.env.NODE_ENV !== 'production') globalForDb.pool = pool;
Which connection string should migrations use?
The direct, session-mode connection string — never the transaction-mode pooler. Migrations take advisory locks and run DDL that must stay held across statements, and a transaction-mode pooler can route the next statement to a different backend where that lock does not exist.
datasource db {
provider = "postgresql"
url = env("DATABASE_URL") // pooled, transaction mode
directUrl = env("DIRECT_DATABASE_URL") // direct, session mode
}
Drizzle Kit takes the same split by pointing its config at the direct URL while the runtime client uses the pooled one. The rule generalises: LISTEN/NOTIFY, advisory locks used as a distributed mutex, and anything that sets a session variable belongs on the direct connection.
When is an HTTP database driver the better choice?
When your handler runs one self-contained statement and you would rather not manage a TCP pool at all. Neon's @neondatabase/serverless package exposes neon(), which sends a single SQL statement over HTTP — no pool to size, no connection to leak. The cost is that HTTP is stateless: no interactive transactions, no session settings. The same package ships a WebSocket-backed Pool for cases that need real transactions.
My rule is boring: HTTP driver for read handlers running one query, TCP pool through a transaction-mode pooler for multi-statement transactions, direct connection for migrations and workers.
FAQ
Q: How large should max be in a serverless connection pool?
A: Small — start around three to five per instance, then watch the server-side connection count at peak concurrency. The number that matters is instances × max.
Q: Do I still need PgBouncer if I use Prisma?
A: Yes. Prisma's client-side pool is per-instance like any other and does not coordinate across processes. Add ?pgbouncer=true so Prisma stops relying on named prepared statements.
Q: Does transaction-mode pooling break database transactions?
A: No. It pins one server connection for the full duration of a transaction. It breaks state that lives between transactions.
Q: Why does my local database run out of connections when production does not?
A: Almost always HMR creating a new pool on every file save. Cache the pool on globalThis in development.
Q: Can I use LISTEN/NOTIFY from a serverless function?
A: Not through a transaction-mode pooler — the listening session is not preserved. Use a direct session connection on a long-lived process, or a real queue.
Originally published on devya.dev. Also on eng-ahmed.com. Built by Devya Solutions.
Top comments (0)