HAMZA KAREEM / CONCEPTS hamzawi.dev

Lesson 62 min read

Connection Pooling

Cloud infrastructurecloud infrastructure / databases

The scenario

Your API runs great in dev. You deploy to production on serverless (Lambda / Cloud Run), traffic hits ~300 req/s, and Postgres starts throwing:

FATAL: sorry, too many clients already

You restart the DB. It works for 2 minutes, then dies again. CPU is low, queries are fast. So what's killing it?

The diagnosis

Each request opened its own database connection. Postgres' default max_connections is ~100. Worse: opening a TCP+TLS+auth handshake to Postgres costs ~1–3ms and real server memory (each backend = several MB of RAM). At 300 concurrent requests you needed 300 connections — and you only had 100.

The fix isn't "raise max_connections to 5000." That just moves the crash from "too many clients" to "out of memory," because every idle connection still holds RAM.

The fix: a pool

A connection pool keeps a small, fixed set of warm connections and lends them out:

// Node + pg
const pool = new Pool({ max: 10 })      // 10 connections, reused
const res = await pool.query('SELECT 1') // borrow → run → return

Request #11 doesn't open a new connection — it waits a few ms for one of the 10 to free up. 10 reused connections can serve thousands of req/s because each query only holds the connection for milliseconds.

The two layers people confuse

  • App-side pool (pg.Pool, HikariCP): lives inside ONE process. Great for a long-running server.
  • External pooler (PgBouncer, Supabase pooler, RDS Proxy): a proxy in front of the DB. Essential for serverless, where you have N short-lived instances that can't share an app-side pool — so each would otherwise hammer the DB directly.

The gotcha that bites intermediate devs

PgBouncer in "transaction mode" multiplexes connections per-transaction. That silently breaks:

  • prepared statements / session-level SET
  • LISTEN/NOTIFY
  • advisory locks held across queries

…because your next query may land on a different backend. Rule of thumb: pool size ≈ a small multiple of CPU cores, NOT your request rate. More connections ≠ more throughput past that point — they just queue inside Postgres.

Your exercise

You run 4 app instances on serverless, each with an app-side pool of max:20, all pointing straight at a Postgres with max_connections=100.

  1. What's the worst-case total connection demand?
  2. Why does this still crash, and what single component would you add to fix it without touching the DB limit?