Postgres connection pooling in serverless: why it breaks and how to fix it
Each serverless invocation opens a new DB connection. At scale, this exhausts Postgres in seconds. PgBouncer, Neon's pooler, and the Neon serverless driver each solve it differently.
Postgres's connection model is a process-per-connection architecture โ every client connection spawns a new OS process consuming 5โ10MB of RAM. A production Postgres instance typically supports 100โ500 simultaneous connections before performance degrades. A serverless deployment with 200 concurrent Lambda invocations each holding their own connection will exhaust that limit in under a second. This is the most common cause of 'too many connections' errors in serverless TypeScript stacks, and the fix is connection pooling.
Why serverless is uniquely bad for connections
A traditional Node.js server opens a connection pool at startup and reuses those connections across requests โ typically 10โ20 connections shared across all traffic. A serverless function has no persistent process: each invocation may open its own connection, hold it for the function's duration, and close it at end. At scale, you get hundreds of simultaneous connections with no sharing. Connection warm-up (50โ100ms to TCP-connect and authenticate) also adds directly to request latency.
// Without pooling: new connection per invocation
// 500 concurrent requests -> 500 Postgres connections -> crash
export const handler = async () => {
const client = new pg.Client(process.env.DATABASE_URL);
await client.connect(); // 50-100ms cold connect
const result = await client.query('SELECT ...');
await client.end();
return result.rows;
};
// With Neon pooled URL: PgBouncer multiplexes
// 500 concurrent requests -> ~20 actual Postgres connections
const db = drizzle(neon(process.env.DATABASE_POOLED_URL));PgBouncer: the standard solution
PgBouncer sits in front of Postgres and maintains a fixed pool of real database connections, multiplexing thousands of client connections onto them. Neon runs PgBouncer in transaction mode with max_client_conn of 10,000 โ your serverless functions connect to PgBouncer, which maintains a pool of real Postgres connections. The -pooler suffix in Neon's connection string routes through this layer. Use it by default for any serverless workload.
The prepared statement gotcha
PgBouncer in transaction mode has one significant limitation: prepared statements don't work correctly. The connection is returned to the pool after each transaction, so a prepared statement created in one transaction is unavailable in the next. Drizzle's default parameterized queries work correctly. Prisma requires specific configuration to disable prepared statements when using a pooler URL. If you see 'prepared statement does not exist' errors, this is the cause.
Options by deployment target
- Neon serverless driver (@neondatabase/serverless): connects over HTTP/WebSockets instead of TCP โ designed for edge runtimes (Cloudflare Workers) that don't support TCP sockets. Zero connection overhead.
- Prisma Accelerate: connection pooler + global query cache between serverless functions and any Postgres database.
- Cloudflare Hyperdrive: connection proxy that keeps warm Postgres connections in Cloudflare's network, available to Workers with sub-millisecond connection latency.
- Self-hosted PgBouncer: if you run your own Postgres, deploy PgBouncer as a sidecar โ the standard approach for any non-managed Postgres deployment.
Written by Appesto Engineering.