0x55aa
Back to Blog

🏊 Connection Pooling in Serverless: The Pool That Keeps Drowning

|
6 min read

Connection pooling is one of those patterns that's so foundational you stop thinking about it. Long-running server, handful of workers, each worker keeps 10-20 warm connections to Postgres, requests borrow a connection and give it back. Simple. It's worked since roughly forever.

Then someone deploys the API as Lambda functions or Vercel edge functions, and the whole model quietly stops making sense — not because the code is wrong, but because the process lifecycle underneath it changed and nobody updated the mental model to match.

Why the old pattern breaks

A connection pool's entire value proposition is reuse: pay the TCP handshake, TLS negotiation, and Postgres auth cost once, then amortize it across thousands of queries. That only works if the process holding the pool sticks around.

A serverless invocation doesn't stick around. Depending on load, you might get:

  • A cold start — brand new process, brand new pool, brand new connections to open
  • A warm reuse — same process, pool survives, this is the good case
  • Simultaneous scale-out — the platform spins up 50 concurrent instances because traffic spiked, and now you have 50 processes, each confidently opening its own pool of 10 connections

That last one is the killer. Fifty instances times ten connections is 500 connections landing on a database that's often configured for something like max_connections = 100. Postgres doesn't queue politely and wait — it starts throwing FATAL: too many connections at everyone, including your health checks.

// This "works" locally and murders your database in prod
import { Pool } from "pg";

const pool = new Pool({ max: 10 }); // fine for one long-lived server

export async function handler(event) {
  const client = await pool.connect();
  try {
    const { rows } = await client.query("SELECT * FROM orders WHERE id = $1", [event.id]);
    return rows[0];
  } finally {
    client.release();
  }
}

The bug isn't in this function. The bug is that this function gets instantiated dozens of times in parallel, and each instantiation thinks it's the only pool in the world.

The fix that doesn't scale: fewer connections per instance

The naive first move is to shrink max to 1 or 2 per instance, reasoning that fewer connections per process times many processes is still bounded. It helps, but it doesn't actually solve anything — it just raises the concurrency ceiling before you hit the wall again. At real traffic spikes you can still end up with hundreds of cold-started instances, and each one still opens at least one connection, still pays the TLS/auth handshake cost on every cold start, and still competes for the same fixed max_connections.

You're treating the symptom. The actual mismatch is architectural: Postgres connections are expensive, stateful, and meant to be long-lived, while serverless instances are cheap, stateless, and meant to be short-lived. No amount of tuning max reconciles those two facts.

The fix that actually scales: a connection pooler as a separate service

The real answer is to stop letting your function processes hold the pool at all, and put a dedicated pooler in front of Postgres instead — something like PgBouncer, or a managed equivalent (RDS Proxy, Neon's built-in pooler, Supabase's pooler). Your serverless functions connect to the pooler; the pooler maintains a small, stable set of real connections to Postgres and multiplexes hundreds of client connections onto them.

// Same function, but it talks to a pooler endpoint, not Postgres directly
const pool = new Pool({
  host: "my-app.pooler.region.rds.amazonaws.com", // RDS Proxy, PgBouncer, etc.
  max: 1, // this instance only ever needs one connection at a time
  connectionTimeoutMillis: 3000,
});

The mental shift here matters: you're no longer trying to make 500 serverless instances share 100 real database connections through discipline. You're making that multiplexing someone else's job — a piece of infrastructure specifically built to sit at that boundary and absorb the churn.

Two pooling modes are worth knowing about if you go the PgBouncer route:

  • Transaction mode — a connection is handed out per transaction, then returned to the pool the moment it commits. This is what you want for typical request/response API traffic, and it's what gets you the high multiplexing ratio.
  • Session mode — a connection is held for the life of the client session, which defeats most of the point for serverless. Skip it unless you specifically need session-level features (like advisory locks or prepared statements) that transaction mode can't support.

At Cubet, we run RDS Proxy in front of a Postgres instance backing a set of Lambda-based webhook handlers — traffic is bursty by nature (everything arrives right after a batch job finishes), and without the proxy we'd cold-start our way into too many connections on every spike. With it, Lambda concurrency can scale into the hundreds while Postgres only ever sees a couple dozen real connections.

The other lever: HTTP-native databases

If you're starting fresh rather than retrofitting, some databases skip the TCP-connection problem entirely by speaking HTTP or a connectionless RPC protocol designed for exactly this environment — Neon's serverless driver, PlanetScale's HTTP API, and Cloudflare D1 all fall into this category. There's no pool to manage on the client side because there's no persistent connection to pool in the first place; each query is a stateless request. This trades away some transaction semantics and adds a bit of per-query latency, but it removes an entire category of production incident.

// Neon's serverless driver — query over HTTP, no pool object at all
import { neon } from "@neondatabase/serverless";

const sql = neon(process.env.DATABASE_URL);

export async function handler(event) {
  const rows = await sql`SELECT * FROM orders WHERE id = ${event.id}`;
  return rows[0];
}

What to actually check before you ship

If you're deploying database-backed code to Lambda, Vercel functions, or Cloudflare Workers, ask three questions before traffic finds the gap for you:

  1. What's my database's max_connections, and what's the realistic peak concurrency of my function?
  2. Is there a pooler between my functions and the database, or is every cold start opening a fresh connection straight to Postgres?
  3. Does my client library's max setting reflect "one serverless instance" (usually 1) rather than "one traditional server" (usually 10+)?

None of this shows up in local dev, where you've got one process and traffic never spikes past whatever you're clicking through manually. It shows up in production, at 2am, as a wall of FATAL: too many connections — right when the traffic spike you should be celebrating is the thing taking your database down.

Try it

Go check your serverless database client's pool config right now. If max is set to anything above 2-3 and there's no pooler sitting between your functions and the database, you're one traffic spike away from finding out the hard way. Fix the architecture, not just the number.

Share:LinkedInXHacker News

Related reading

Thanks for reading!

Back to all posts