Deep diveOct 21, 20269 min read

P2024 on serverless: the connection-pool math nobody does

Every cold start opens its own pool. Here is how 40 Lambdas turn into 200 Postgres connections, and the three settings that bring it back to 40.

The error reads like a timeout, so the first instinct is to raise the timeout. That almost never helps. P2024 means a query waited pool_timeout seconds (10 by default) for a free connection in the pool and none came back. The pool is full; the question is why.

P2024Critical↻ retryableConnection pool timeoutAll connections in Prisma's pool were busy for longer than pool_timeout (default 10s). Reuse one PrismaClient, size connection_limit for your platform, and use a pooler for serverless.

The math

Prisma sizes each pool at num_physical_cpus * 2 + 1 unless you set connection_limit. On a 2-vCPU function that is 5 connections. Serverless platforms run one pool per warm instance, so concurrency multiplies it.

pool-math.js
// 2 vCPU → default pool of 5 per instance
// 40 concurrent instances during a traffic spike
const perInstance = 2 * 2 + 1;      // 5
const instances = 40;
const total = perInstance * instances; // 200 connections
// Postgres max_connections on a small managed plan: often 100
// → instances 21..40 wait, then throw P2024 (or Postgres 53300)

When Postgres itself runs out first you see 53300 too_many_connections instead. Same root cause, different layer.

Fix 1 · One connection per instance

A serverless instance handles one request at a time, so it rarely needs more than one connection. Pin the pool and let the pooler do the fan-in.

.env
DATABASE_URL="postgresql://app:***@db-pooler.example.com:6543/app?pgbouncer=true&connection_limit=1&pool_timeout=20"
DIRECT_URL="postgresql://app:***@db.example.com:5432/app"

Fix 2 · Put a pooler in front

PgBouncer in transaction mode (or your host’s built-in pooler) multiplexes thousands of client connections onto a few dozen server connections. Keep directUrl for migrations: they need session features the pooler strips.

schema.prisma
datasource db {
  provider  = "postgresql"
  url       = env("DATABASE_URL")   // pooled
  directUrl = env("DIRECT_URL")     // migrations only
}

Fix 3 · Reuse the client across invocations

lib/prisma.ts
import { PrismaClient } from "@prisma/client";

const g = globalThis as unknown as { prisma?: PrismaClient };
export const prisma = g.prisma ?? new PrismaClient();
if (process.env.NODE_ENV !== "production") g.prisma = prisma;
Tip

Watch the ratio, not the number: alert when active connections exceed 80% of max_connections for five minutes. By the time P2024 shows up in Sentry you are already shedding requests.

What not to do

  • Raising pool_timeout to 60s: requests now hang a minute before failing.
  • Raising max_connections to 1000: each Postgres backend costs memory; you trade P2024 for OOM kills.
  • Calling $disconnect() at the end of every handler: the next invocation pays the TLS handshake again.