All posts
Databases & Storage··10 min read

Connection Pooling in Serverless: Why Postgres Falls Over and How to Fix It

Serverless plus Postgres is a footgun: every function instance opens connections until the database collapses. Here is how pooling actually fixes it.

By

On this page

The first time I watched a serverless app take down its own database, traffic was barely a blip. A few hundred requests per second, a db.t3.medium that should have shrugged it off. Instead the logs filled with FATAL: sorry, too many clients already and the API started returning 500s. The database CPU was nearly idle. It had simply run out of connections.

This is the canonical serverless-meets-Postgres failure, and it catches teams who have run Postgres successfully for years. The mental model that worked for long-lived servers is exactly the model that breaks under serverless. Let me walk through why, and what actually fixes it.

Why serverless detonates Postgres

A Postgres connection is not cheap. Every backend connection forks a dedicated OS process on the server, reserving somewhere between 5 and 15 MB of memory plus parser, planner, and snapshot state. That is why max_connections defaults to 100, and why managed providers cap it tightly: an AWS RDS db.t3.medium allows roughly 410 connections, and small instances far fewer.

On a traditional server, this is a non-problem. You boot one Node process, create a pool of maybe 10 connections, and every request borrows from that pool. Ten servers means 100 connections. Predictable.

Serverless inverts the relationship between requests and processes. Each concurrent function invocation runs in its own isolated instance with its own memory space. There is no shared pool across instances, because there is no shared process. If your platform spins up 300 concurrent Lambda or Vercel function instances to absorb a traffic spike, and each one opens a single connection, that is 300 connections. The math is not subtle:

connections = concurrent_instances × pool_size_per_instance

Set pool_size_per_instance to a conservative 2 and let the platform scale to 300 instances, and you need 600 connections against a database that allows 410. The database does not fail gracefully. It rejects new connections outright, and because serverless retries aggressively, a connection-exhausted database stays exhausted.

Cold starts make it worse. A freshly booted instance has to complete the TCP handshake, TLS negotiation, and Postgres authentication before it can serve a request. That handshake is 30 to 100 ms of latency on the critical path of your first request, and during a scale-up event you pay it across hundreds of instances at once, each one grabbing a connection slot it will hold idle between requests.

The fix is a pooler, not a bigger database

Bumping max_connections is the instinct, and it is wrong. Each connection still costs memory and scheduler overhead; pushing the limit to 2000 just moves the failure from "connection refused" to "database swapping itself to death." The real fix is to put a connection pooler between your functions and Postgres.

A pooler maintains a small set of real backend connections to Postgres and multiplexes a large number of client connections across them. Your 600 hungry function instances connect to the pooler; the pooler holds 25 actual connections to Postgres and hands them out as queries arrive. The classic implementation is PgBouncer; Supabase ships Supavisor, a Postgres-aware pooler that scales horizontally, and most managed providers (Neon, RDS Proxy, Supabase) now offer a pooler endpoint you flip on without running anything yourself.

Here is a minimal PgBouncer config for serverless workloads:

[databases]
appdb = host=10.0.1.20 port=5432 dbname=appdb
 
[pgbouncer]
listen_addr = 0.0.0.0
listen_port = 6432
auth_type = scram-sha-256
auth_file = /etc/pgbouncer/userlist.txt
 
# Transaction mode is what makes serverless viable
pool_mode = transaction
 
# Real connections to Postgres, shared across everyone
default_pool_size = 25
max_client_conn = 5000
 
# Reap idle server connections so cold instances do not hoard slots
server_idle_timeout = 30

That max_client_conn = 5000 against default_pool_size = 25 is the whole trick: five thousand functions can connect, but only twenty-five touch Postgres at any instant. The application connects to port 6432 instead of 5432, and otherwise nothing changes — except the constraints I am about to describe.

Pooling modes, and the one you must use

PgBouncer supports three pooling modes, and the difference between them is the single most misunderstood part of serverless Postgres.

ModeConnection returned to poolPrepared statementsSession settings (SET, search_path)LISTEN/NOTIFY, advisory locksUse for serverless?
SessionWhen client disconnectsYesYesYesNo — one connection per instance, no multiplexing
TransactionAt each COMMIT/ROLLBACKRestrictedNo (cleared per txn)NoYes — the right default
StatementAfter every statementNoNoNoRarely — forbids multi-statement transactions

Session mode pins a backend connection to a client for the client's entire lifetime. Under serverless, a function instance lives for minutes and stays idle between requests, so session mode gives you zero multiplexing — you are back to the connection-per-instance problem with extra hops.

Transaction mode is the answer. The pooler only assigns a Postgres connection to your client for the duration of a single transaction, then reclaims it. This is what lets 25 backend connections serve thousands of clients: nobody holds a connection while idle.

The catch — and it is a sharp one — is that transaction mode breaks anything that relies on connection-level state living longer than one transaction. The query you ran on connection A might return to the pool and your next query lands on connection B. So SET search_path, session variables, LISTEN/NOTIFY, session-level advisory locks, and naively-issued prepared statements all break, because the state they depend on lives on a connection you no longer own.

What this does to Prisma and Drizzle

This is where ORMs bite you, because they love prepared statements. A prepared statement is parsed and planned once, then executed many times by name — great for a long-lived connection, useless when the next execute lands on a connection that never saw the PREPARE.

For years the fix was a magic query parameter telling the driver to disable prepared statements:

// The old workaround — disable prepared statements at the pooler endpoint
const url =
  "postgresql://user:pass@pooler.example.com:6432/appdb?pgbouncer=true";
// `pgbouncer=true` tells Prisma to run in a prepared-statement-free mode

Modern PgBouncer (1.21+) supports protocol-level prepared statements in transaction mode via max_prepared_statements, and current Prisma and Drizzle releases negotiate this correctly — but only if the pooler is configured for it. If you are on an older pooler, or a managed endpoint that has not enabled it, you still disable prepared statements client-side. With Drizzle and postgres.js, that looks like:

import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";
 
// `prepare: false` is mandatory against a transaction-mode pooler
// that does not support protocol-level prepared statements
const client = postgres(process.env.DATABASE_URL!, {
  prepare: false,
  max: 1, // one connection per instance; the POOLER does the real pooling
  idle_timeout: 20,
  connect_timeout: 10,
});
 
export const db = drizzle(client);

Two things matter here. First, prepare: false. Second, max: 1. In a serverless function you do not want a client-side pool of 10 — that multiplies against instance count and defeats the purpose. Keep the client-side pool tiny and let the external pooler do the multiplexing. The pooled and direct connection strings live side by side, and which one you use depends on the operation:

# Pooled — transaction mode, port 6432. Use this for your APP at runtime.
DATABASE_URL="postgresql://user:pass@db.example.com:6432/appdb?pgbouncer=true"
 
# Direct — port 5432, full session features. Use this for MIGRATIONS,
# which need DDL, advisory locks, and prepared statements.
DIRECT_URL="postgresql://user:pass@db.example.com:5432/appdb"

Migrations need the direct connection because schema changes use advisory locks and session features that transaction pooling strips away. Prisma formalizes this with the directUrl field in the datasource block; route runtime traffic through the pooler and migrations through the direct port.

The serverless-native alternative: drivers over HTTP

There is a second school of thought that sidesteps TCP connections entirely. The Neon serverless driver speaks to Postgres over HTTP for one-shot queries and over WebSocket for transactions and sessions. There is no persistent TCP connection to keep warm and no handshake on the request path — which is exactly what you want in an edge runtime where raw TCP is not even available.

import { neon } from "@neondatabase/serverless";
 
const sql = neon(process.env.DATABASE_URL!);
 
// A single HTTP round-trip. No pool, no connection lifecycle to manage.
export async function getUser(id: string) {
  const [user] = await sql`
    SELECT id, email, created_at
    FROM users
    WHERE id = ${id}
  `;
  return user;
}

For a stateless query inside an edge function, this is the lowest-overhead option available — the query and its result fit in a single HTTP exchange, and the pooling happens server-side at the Neon proxy. The tradeoff is lock-in to that driver's protocol and provider, and HTTP-mode queries cannot span a transaction (you reach for the WebSocket Pool for that, which reintroduces a connection lifecycle). It is an excellent fit for read-heavy edge endpoints, less so for complex transactional write paths.

Does Fluid Compute make this go away?

Partly. Fluid Compute (Vercel's model, and similar instance-reuse schemes elsewhere) lets a single warm instance handle multiple concurrent invocations instead of cold-booting one instance per request. That reduces fanout meaningfully: a warm instance with a small client-side pool can serve many requests on a handful of connections, and you pay the cold-start handshake far less often.

But it reduces the problem; it does not eliminate it. You still scale horizontally under load, so you still have N instances times a per-instance pool. A traffic spike still fans out to many instances. Fluid Compute lowers the multiplier and lets per-instance pooling earn its keep, but the moment your concurrency exceeds what one instance absorbs, the connection math reasserts itself. Treat instance reuse as a discount on the pooler, not a replacement for it.

Sizing the pool, and keeping it warm

The pooler's default_pool_size should be derived from the database, not guessed. Reserve headroom for migrations, admin sessions, and replication, then divide the rest across your pooler instances:

default_pool_size = (max_connections − reserved) / pooler_instances
 
# Example: db allows 410, reserve 60 for admin/migrations/replication,
# one pooler instance:
# (410 − 60) / 1 = 350 → cap well under that, e.g. 200, for safety margin

Bigger is not better. A pool larger than your CPU core count just queues work inside Postgres and adds context-switch overhead; the often-cited starting point is cores × 2 + effective_spindle_count, and for connection-bound serverless workloads you want the pool small and the client-side wait short. Measure with SELECT count(*) FROM pg_stat_activity under load and size to your actual concurrent query count, not your request count.

Keeping pools warm matters more in serverless than anywhere else. Set server_idle_timeout low enough that idle instances release backend connections, but pair it with a lightweight health-check or scheduled ping if your provider tears down warm instances aggressively — a single SELECT 1 every 30 seconds keeps a warm path open and dodges the cold handshake on the next real request.

The decision checklist

When you put serverless in front of Postgres, work down this list:

  • Never connect functions directly to port 5432. Always go through a transaction-mode pooler (PgBouncer, Supavisor, or a managed pooler endpoint).
  • Use transaction mode, not session mode, or you get zero multiplexing.
  • Set the client-side pool to 1 (or 2). The external pooler does the real pooling; a large client-side pool multiplies against instance count.
  • Disable prepared statements (prepare: false / ?pgbouncer=true) unless your pooler explicitly supports protocol-level prepared statements in transaction mode.
  • Run migrations through the direct (directUrl) connection, never the pooler — they need session features and advisory locks.
  • For edge runtimes and stateless reads, prefer an HTTP-based driver like Neon's; it removes the connection lifecycle entirely.
  • Size default_pool_size from max_connections, reserve headroom, and keep it near your CPU core count — not your request count.
  • Treat Fluid Compute as a discount, not a cure. It lowers fanout; it does not remove the connection math.

Get the pooler right and Postgres will happily sit behind ten thousand serverless instances on twenty-five connections, idle CPU and all. Get it wrong and you will watch an idle database refuse traffic at a few hundred requests per second. The difference is one config file and one connection string — and knowing which mode you are actually in.