Pooling on serverless: the multiplication problem, pgbouncer transaction mode, and per-lambda sizing
Every lambda owns a pool: 100 invocations times pool 5 is 500 connections against max_connections 100 — FATAL: too many clients. Per-instance pools shrink to 1-2; the fix ladder: pgbouncer transaction mode with its costs, RDS Proxy, HTTP drivers. Cold starts dial in herds.
Launch morning. The 09:00 marketing email lands and traffic jumps fortyfold — which is exactly what the team sized for: serverless scales out, the database is a comfortable two sizes up, load tests passed. At 09:02 the error tracker floods with a sentence nobody on the team has seen before: FATAL: sorry, too many clients already. The dashboard makes it stranger — Postgres CPU is at 8 percent. The database is not overloaded; it is full. Each function instance dutifully created its own connection pool of five, the platform scaled to a hundred-and-some concurrent instances, and 500 connection attempts marched into a max_connections of 100. Retries doubled the herd. The fix that morning was not a bigger database — it was understanding that on serverless, every instinct you have about pool sizing is multiplied by a number you do not control.
The multiplication problem
On a serverful deployment, pooling wisdom is settled: three app servers with a pool of 20 each hold 60 warm connections, stable for years. Serverless breaks the assumption hiding inside that math — that the number of pool owners is small and fixed. A lambda-style instance handles one request at a time, and the platform scales by adding instances; the pool is per-process, and processes cannot share sockets. So a pool of 5 per instance buys almost nothing — one request can rarely use five connections at once — while every instance holds five slots anyway. The arithmetic to write on the whiteboard: peak concurrent instances × per-instance pool size must stay under max_connections minus the reserved slots. With Postgres defaults that ceiling is 100, minus superuser_reserved_connections (3) and whatever your migrations, cron jobs, and dashboards hold. 100 concurrent invocations × pool 5 = 500 demanded; the launch-day incident is not bad luck, it is multiplication.
Why not just raise max_connections to 5000? Because Postgres connections are expensive by design: each one is a forked backend process, with per-backend memory (work_mem is per sort/hash operation, multiplying with complexity) and scheduler overhead. Past a few hundred active backends, throughput degrades even when most sit idle. That is why the wisdom inverts: on serverless the per-instance pool should be 1–2. Prisma’s default pool size is num_physical_cpus × 2 + 1 — sized for a server, wrong for a lambda; set connection_limit=1 in the connection string and let concurrency come from instances, not from threads inside one.
The fixes ladder
Each rung below trades some feature for a smaller per-function connection footprint — understand what you lose before you reach for the next rung.
Rung one: an external pooler — pgbouncer in transaction mode. Clients connect to pgbouncer, which is cheap per connection (kilobytes of state, no forked process), and pgbouncer maintains a small pool of real server connections — say default_pool_size = 20. In transaction mode, a server connection is assigned to a client only for the duration of one transaction, then returned; 500 client connections multiplex over 20 backends, and Postgres never feels the herd. The costs are real and specific. Anything that lives on a session breaks: SET settings silently apply to whichever backend you get next, session-level advisory locks and temp tables evaporate, LISTEN/NOTIFY does not work. And protocol-level prepared statements break by default — a statement prepared on one backend does not exist on the next — which is the classic ORM landmine: Prisma needs ?pgbouncer=true in the URL to stop relying on them, postgres.js needs prepare: false. (Newer pgbouncer, 1.21+, can track prepared statements in transaction mode via max_prepared_statements — check before assuming either way.)
Rung two: managed proxies — RDS Proxy and friends. Same multiplexing idea, run as a service, with IAM auth and failover handling. The catch to know: session state causes pinning — a connection that runs SET or other session-y things gets pinned to one client, quietly turning your multiplexer back into a 1:1 mapper under exactly the workloads that misbehave.
Rung three: HTTP/WebSocket serverless drivers — the Neon and PlanetScale style. The driver speaks HTTP or WebSocket to a provider-side gateway that owns the real pool; your function never holds a TCP connection to the database at all. This sidesteps the multiplication entirely and works in edge runtimes where raw TCP is unavailable. The tradeoffs: per-query gateway latency, provider coupling, and interactive transactions need the WebSocket flavor — a plain HTTP round trip per statement cannot hold a transaction open.
Functions run with pool size 5; traffic peaks at 100 concurrent invocations; Postgres has max_connections=100. Ops responds to the incident by raising max_connections to 200. What happens at the next peak?
Sizing arithmetic, with the herd included
Run the numbers the way you would in a design review. Managed Postgres, max_connections = 100, 3 reserved: 97 usable, and other consumers — migrations, a cron worker, an analytics reader — realistically hold 10–15, leaving ~85 for the app. Direct connections with connection_limit=1 cap you at 85 concurrent invocations before FATAL; with the default pool of 5 the cap is 17. Through pgbouncer in transaction mode the same database serves thousands of client connections over default_pool_size = 20 backends, and your real limit becomes transaction throughput — 20 backends at 5 ms per transaction is roughly 4,000 transactions a second, far past the original wall.
Now add the failure mode that sizing tables miss: cold-start herds. A deploy replaces every instance at once; the next minute of traffic is served entirely by cold starts, and each one dials TCP + TLS + auth simultaneously. Direct to Postgres, that is a connection storm into a fixed ceiling — transient FATALs even when steady-state math was fine. Behind a pooler, the storm lands on pgbouncer, which absorbs it as a queue (latency blip, not errors). The other serverless wrinkle: a frozen instance holds its connection without sending keepalives until the platform thaws or reaps it, so the database accumulates idle connections owned by processes that may never wake — set aggressive idle timeouts on both sides, and prefer poolers or HTTP drivers that make instance death invisible.
▸Why this works
Why does pgbouncer transaction mode break prepared statements at all? Because protocol-level prepared statements are named objects living in one backend’s memory: the client says PREPARE s0 once, then EXECUTE s0 cheaply. That contract assumes the client keeps talking to the same backend. Transaction mode deliberately breaks that assumption — it hands you whichever backend is free at each BEGIN — so s0 exists on the backend that prepared it and nowhere else. The ORM flags (pgbouncer=true, prepare: false) switch clients to sending full statements each time; pgbouncer 1.21+ instead tracks and replays the PREPAREs per backend. Same root cause as the LISTEN/NOTIFY and SET breakage: anything whose lifetime is a session cannot survive a pooler whose unit of lease is a transaction.
After pointing DATABASE_URL at pgbouncer in transaction mode, Prisma queries start failing with: prepared statement 's0' does not exist. What broke?
- 01Write the serverless connection arithmetic and explain why raising max_connections is not the fix.
- 02What exactly does pgbouncer transaction mode cost you, and where do ORMs trip on it?
Serverless inverts pool sizing because it multiplies pool owners: each instance serves one request at a time and owns its own per-process pool, so peak concurrent instances times per-instance pool size is your real connection demand, and it must clear max_connections minus reserved slots. The launch-day FATAL with the database at 8 percent CPU is that multiplication, not load — and raising max_connections mostly relocates the wall, because each Postgres connection is a forked backend process with real memory and scheduling cost. Per-instance pools shrink to 1–2 (override Prisma’s server-shaped default with connection_limit=1), and pooling moves to a layer that all instances share. The ladder: pgbouncer in transaction mode multiplexes hundreds of cheap client connections over a small backend pool, at the price of everything session-shaped — SET, session advisory locks, temp tables, LISTEN/NOTIFY — and of protocol-level prepared statements, the classic ORM landmine fixed by pgbouncer=true or prepare: false, or natively by pgbouncer 1.21+ statement tracking. Managed proxies buy the same multiplexing as a service but pin connections when session state appears. HTTP/WebSocket drivers remove the client-side connection entirely and work on edge runtimes, trading per-query gateway latency and provider coupling, with interactive transactions needing the WebSocket path. Finally, size for the herd, not just steady state: a deploy means every instance dials cold at once, and frozen instances hold idle connections the platform may never thaw — a pooler converts the storm into a queue, idle timeouts clean up the zombies, and the whiteboard arithmetic — instances × pool versus ceiling — is the one-line review that prevents the whole incident class. Now when you see FATAL: too many clients in your error tracker, run the multiplication before touching max_connections: peak instances times pool size is almost always the number that explains it.
Practice
Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.
Apply this
Put this lesson to work on a real build.