← Blog

Postgres connection pooling for serverless Next.js: why connections multiply, and how to stop it

Each serverless instance opens its own Postgres pool, so connections multiply with traffic: size the pool, add a pooler, and know when prepare must go.

The database works perfectly in development and through the first week of production. Then a traffic spike arrives — a launch, a Reddit thread — and requests start failing with errors about too many connections, even though the database itself is barely busy. Nothing is wrong with the queries. The problem is arithmetic: on serverless, every instance of your app opens its own connection pool, and the number of instances is set by your traffic, not by you.

Connections are instances times pool size

In a long-running Node server, one process holds one pool, and the pool size is your connection count. On serverless that stops being true. Each concurrent function instance is its own process with its own pool, so the real number is instances × pool size — and instances scale with concurrent requests. Fifty concurrent instances with a pool of 10 is 500 connections. Postgres's own default max_connections is 100, and hosted providers set their own ceilings by plan. A pool size that was harmless on one server becomes a hard failure at the first real spike.

Keep the per-instance pool small

The first lever is the pool size. The postgres.js driver defaults to max: 10; the kit sets it to 5, and says why in the code:

// postgres.js connects lazily (on first query), so constructing the client
// with a placeholder is safe at build time when DATABASE_URL is absent.
const PLACEHOLDER_URL = "postgres://placeholder:5432/placeholder";

function create() {
  // Small pool: Next.js route handlers share this process, and serverless
  // Postgres providers meter connections.
  const sql = postgres(process.env.DATABASE_URL ?? PLACEHOLDER_URL, { max: 5 });
  return drizzle(sql, { schema });
}

export function db() {
  return (instance ??= create());
}

A single function instance rarely needs many parallel queries — a request handler typically runs a handful, often sequentially — so a small pool costs little throughput and divides your connection footprint. The lazy singleton matters too: the client is created once per instance and reused across the requests that instance serves, instead of opening a new pool per request. (Why the client is constructed lazily at all is covered in making clients lazy so the build works without env vars.)

Put a pooler in front of the database

A small pool reduces the multiplier; it does not remove it. The real fix for serverless is a connection pooler — PgBouncer or your provider's built-in equivalent — sitting between your instances and Postgres. Your functions open many cheap connections to the pooler, and the pooler multiplexes them onto a small, fixed set of real database connections. Most hosted Postgres services offer this as a separate "pooled" connection string; point serverless code at that one and keep the direct string for migrations and admin work.

The prepared-statement trap

Poolers in transaction mode — the mode that makes serverless pooling efficient — hand each transaction to whichever server connection is free, which breaks anything that assumes a session sticks to one connection. Prepared statements are the classic casualty, and postgres.js creates them automatically (prepare: true by default). The driver's documentation is direct about the fix: set prepare: false when running behind PgBouncer in transaction mode. It also notes that PgBouncer 1.21 and later can support protocol-level prepared statements when configured with max_prepared_statements — so check what your provider runs before turning the optimization off.

Things that bite

  • Idle connections never close by default. In postgres.js, connections stay open until .end() is called — idle_timeout defaults to 0. The driver's docs recommend setting idle_timeout (and optionally max_lifetime) in serverless environments, so frozen or recycled instances do not pin connections. The kit leaves both at their defaults; add them if your platform keeps instances warm for a long time.
  • Migrations want the direct connection. Schema changes and long admin transactions behave better on a direct, non-pooled connection. Keep two connection strings and use the pooled one only for request handling.
  • Watch the dashboard, not the error log. By the time the app is throwing connection errors, users are already seeing failures. Your provider's connection-count graph shows you approaching the ceiling well before you hit it.
  • A bigger pool is rarely the fix. When connections run out under load, raising max makes the multiplication worse. Lower it, add a pooler, or both.

Shipwright — the Next.js and Claude starter kit this blog documents — ships the lazy, single-instance Drizzle client with a deliberately small pool, so the metering and billing writes on every request do not turn into a connection storm under load. Try the metered demo in the live demo.

Shipwright is a Next.js 16 + Claude starter kit that ships these patterns already done.

Try the live demo →