Postgres Connection Pool Exhausted — Serverless Gotcha
The API is fine at 10 requests per minute. Black Friday traffic hits. Every Lambda cold start opens its own Postgres connection (or five, because your ORM’s pool max defaults to 10). Postgres max_connections is 100. You get sorry, too many clients already and a status page incident. This is the serverless connection multiplier problem — not “Postgres is slow.”
Why serverless multiplies sockets
A traditional Node server holds one pool for the process lifetime. Lambda, Cloud Functions, and many edge-adjacent Node isolates:
- Create a new runtime often.
- Keep a warm instance that still holds sockets from the last invocation.
- Scale to hundreds of concurrent instances, each with its own pool.
Math: instances × pool_max must stay under max_connections minus admin headroom. Fifty warm Lambdas × max: 10 = 500 attempted clients. The database was sized for a handful of app servers.
Symptoms and where to look
SELECT count(*), state, application_name
FROM pg_stat_activity
GROUP BY 1, 2, 3
ORDER BY 1 DESC;
| Observation | Interpretation |
|---|---|
Hundreds of idle from same app name | Pools oversized or instances not releasing |
remaining connection slots are reserved | You hit max_connections |
| Intermittent only on traffic spikes | Autoscaling opened new runtimes |
| Works on one developer laptop | Single process hides the bug |
Also check RDS/Aurora “DatabaseConnections” CloudWatch metric against your instance class limits.
Fix layers (use more than one)
1. External pooler (required for serious serverless). PgBouncer, RDS Proxy, Supabase pooler, Neon pooler, Prisma Accelerate — something that multiplexes many client connections onto fewer Postgres backends. Point the app at the pooler URL. Use transaction pooling mode unless you need session features (temp tables, prepared statements carefully).
2. Shrink the in-process pool. For Lambda:
import { Pool } from "pg";
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
max: 1, // one connection per runtime is plenty behind a pooler
idleTimeoutMillis: 10_000,
});
ORMs: Prisma connection_limit=1 in the URL, drizzle/node-postgres similarly. max: 10 copied from a long-lived server guide is wrong here.
3. Do not open a pool per request. Module-scope singleton. Creating new Pool() inside the handler is an instant outage pattern.
4. Separate migration connections. CI migrate jobs and the app should not compete blindly; migrations can use a direct (non-pooled) URL with a low parallelism setting.
Prisma / ORM footguns
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
directUrl = env("DIRECT_URL") // migrations against primary
}
Use the pooled URL for the app, direct URL for prisma migrate. Running migrate through PgBouncer transaction mode often breaks. Mixing them is how you get “works in app, migrate fails” — the opposite incident from connection exhaustion, but same config surface.
Load-test like production
Staging with one container never reproduces this. Script concurrent invocations (or k6 against an API that hits the DB) until you see connection counts climb. Alert when DatabaseConnections > 70% of max. Cap reserved connections for superuser so you can still pg_terminate_backend during an incident.
Incident break-glass
SELECT pg_terminate_backend(pid)
FROM pg_stat_activity
WHERE application_name = 'my-lambda'
AND state = 'idle'
AND pid <> pg_backend_pid();
That buys time. The durable fix is pooler + tiny per-instance pools + connection metrics on the dashboard before the next spike.
Serverless did not make Postgres worse. It made “one pool per server” into “one pool per snowflake.” Design for the snowflake count.