Skip to main content
Database Connections in Serverless: Pool Math, PgBouncer Transaction Mode, and the Flags That Survive It

Database Connections in Serverless: Pool Math, PgBouncer Transaction Mode, and the Flags That Survive It

August 18, 2026
Full-Stack Engineering
8 min read

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.

We have now debugged the error FATAL: sorry, too many clients already on more than one client deployment where the database itself was idle and the application code was correct. The broken assumption is always the same: a team configures one pool with a sensible max and assumes that number describes the whole deployment. It describes one function instance. These are our team's notes, updated for what Vercel Fluid Compute changed about the arithmetic.

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 the max you configured.

PostgreSQL ships with max_connections = 100 by default and reserves three for superusers, and every connection is a separate backend process with its own memory — the ceiling is real, not a configuration nag.

PgBouncer in transaction mode multiplexes many clients onto few server connections, but it discards session state: protocol-level prepared statements, LISTEN/NOTIFY, session-level SET, and advisory locks held between transactions all break.

Drizzle with postgres.js needs prepare set to false behind a transaction-mode pooler; Prisma needs pgbouncer=true on the application URL plus a separate directUrl for migrations.

Vercel Fluid Compute reuses one instance across concurrent requests, so a module-scope pool now serves several in-flight queries at once — the old advice of setting max to 1 serializes handlers and is no longer the right default.

Why does a serverless app run out of PostgreSQL connections?

Because each function instance runs its own Node.js process holding its own pool, so the connections actually opened are concurrent instances multiplied by each pool's max. Nothing about a pool is shared between processes. Ten instances configured with a max of 10 is one hundred connections, not ten.

That product collides with a hard server-side limit. PostgreSQL defaults to max_connections = 100, holds three of those back for superuser access, and spawns a separate backend process per connection with its own memory allocation. The failure is load-shaped: a traffic spike makes the platform open more instances, each new instance opens its own pool, and PostgreSQL answers with FATAL: sorry, too many clients already. Database CPU stays flat throughout, which is why the error reads as arbitrary the first time a team sees it.

What did Fluid Compute change about connection pooling?

Vercel 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 serverless advice — set max to 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 and converts a fast query into a queue.

Our current default is three settings. Create the pool at module scope so it survives across invocations rather than being rebuilt per request. Keep max small, a handful rather than dozens, because that number is multiplied by instance count. Set a short idleTimeoutMillis, around ten seconds, so connections return to the pooler instead of squatting on a server slot between bursts.

Which PgBouncer pooling mode should we 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 its pool_mode setting decides how long a client keeps one of those server connections.

Session mode holds a server connection for the whole client session, breaks nothing, and is the right choice for migrations, LISTEN/NOTIFY, and long-lived workers.

Transaction mode holds a server connection for one transaction only. It breaks prepared statements, session-level SET, advisory locks held outside a transaction, and WITH HOLD cursors. It is the correct mode for serverless application traffic.

Statement mode holds a server connection for a single statement, breaks everything transaction mode breaks plus multi-statement transactions, and is rare outside sharded setups that never use transactions.

Transaction mode is what makes serverless viable against PostgreSQL: hundreds of client connections share a few dozen server connections, because a typical handler holds its connection for milliseconds. Supabase now fronts PostgreSQL with Supavisor rather than PgBouncer, and Neon and Amazon RDS Proxy ship their own implementations, but the mode semantics are identical everywhere. What you are really choosing is how much session state you are willing to lose.

Why do prepared statements break behind the pooler?

Because a protocol-level prepared statement is session state on one server connection, and transaction mode may hand the next transaction a different server connection. The symptom is a pair of errors alternating under load: prepared statement s1 already exists, when the driver prepares on a connection that already has it, and prepared statement s1 does not exist, when it executes on one that never did.

There are three honest fixes. PgBouncer 1.21 and later can track prepared statements in transaction mode when max_prepared_statements is set above zero — confirm your provider actually enables it before depending on it. Otherwise disable them in the driver: node-postgres only uses named prepared statements when a query is explicitly named, so it is usually safe as shipped; postgres.js prepares by default and needs prepare set to false; Prisma needs pgbouncer=true appended to the pooled connection string.

How do we 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 a new Pool() call at module scope runs again on every file save while the previous pool keeps its sockets open. Twenty saves in an afternoon is twenty live pools, and eventually the local database refuses connections — a production-shaped error nobody can reproduce in production.

The pattern is to read an existing pool off globalThis, create one only if it is missing, and assign it back only when NODE_ENV is not production. The globalThis object survives module re-evaluation, so every later evaluation reuses the same pool, while production keeps exactly one clean pool per instance.

Which connection string should migrations use?

The direct, session-mode connection string — never the transaction-mode pooler. Schema migrations take advisory locks and run DDL that must stay held across multiple statements, and a transaction-mode pooler can route the next statement to a different backend where that lock does not exist. Managed providers hand you both URLs for exactly this reason: a pooled one, usually on a separate port, for application traffic, and a direct one for migrations.

Prisma models this with url pointing at the pooled string and directUrl pointing at the session-mode string. Drizzle Kit takes the same split by pointing its config file at the direct URL while the runtime client uses the pooled one. The rule generalises past migrations: LISTEN/NOTIFY, advisory locks used as a distributed mutex, and anything that sets a session variable and expects it to persist all belong on the direct connection.

When is an HTTP database driver the better choice?

When a handler runs one self-contained statement and the team would rather not manage a TCP pool at all. The Neon package @neondatabase/serverless exposes a neon() function that sends a single SQL statement over HTTP: no pool to size, no connection to leak, no pooler mode to reason about. The cost is that HTTP is stateless — no interactive transactions spanning statements and no session settings. The same package ships a WebSocket-backed Pool for cases that need real transactions.

Our rule is deliberately boring. HTTP driver for read handlers that run one query. TCP pool through a transaction-mode pooler for anything opening a multi-statement transaction. Direct connection for migrations and background workers. Using all three in one application is normal architecture, not a smell.

FAQ

Q: How large should max be in a serverless connection pool?

A: Small — start in the range of three to five per instance, then watch the server-side connection count at peak concurrency. The number that matters is concurrent instances multiplied by max, not max alone.

Q: Do we still need PgBouncer if we use Prisma?

A: Yes. The Prisma client-side pool is per-instance like any other and does not coordinate across processes. Add pgbouncer=true to the pooled URL so Prisma stops relying on named prepared statements.

Q: Does transaction-mode pooling break database transactions?

A: No. A transaction-mode pooler pins one server connection for the full duration of a transaction. It breaks state that lives between transactions, such as prepared statements, session-level SET, and advisory locks taken outside a transaction.

Q: Why does a local database run out of connections when production does not?

A: Almost always hot module replacement creating a new pool on every file save while old pools keep their sockets. Caching the pool on globalThis in development removes the symptom.

Q: Can we use LISTEN/NOTIFY from a serverless function?

A: Not through a transaction-mode pooler, because the listening session is not preserved between transactions. Use a direct session connection on a long-lived process, or a real queue.