Skip to content

Connection exhaustion on serverless Postgres, and how to actually fix it

Serverless does not queue requests on a pool, it creates pools. Here is the arithmetic, the four real causes, and the fix for each.

Neon4 min readships at docs/solutions/neon/connection-exhaustion-in-serverless.md

Tags: neon · serverless · postgres · connection-pooling · vercel · performance

Everything is fine, then a burst of traffic arrives and the logs fill with one of these:

FATAL: sorry, too many clients already
FATAL: remaining connection slots are reserved for non-replication superuser connections
Error: Connection terminated due to connection timeout
Error: timeout exceeded when trying to connect

The queries did not change. The traffic did. What broke is an assumption the whole Node ecosystem is built on.

The arithmetic

A traditional server is one process with one pool. Ten connections, forever, no matter how many requests arrive: the eleventh request waits on the pool.

Serverless inverts this. Each concurrent request can be routed to a fresh instance. Each instance evaluates your module graph, constructs its own client, and opens its own connections. Requests do not queue on a pool; they create pools.

So the connection count is not pool_size. It is:

concurrent instances × connections per instance

With a modest per-instance pool of 5 and 60 concurrent invocations, that is 300 connections. A small Neon compute allows a few hundred including the ones Postgres reserves for itself. You do not need much traffic to lose; a cache miss on a popular page is enough.

Fix 1: route runtime traffic through the pooler

Use the -pooler connection string for everything the app does. PgBouncer in transaction mode accepts thousands of client connections and multiplexes them onto a few dozen real backends, holding one only for the duration of a transaction.

DATABASE_URL=postgresql://...@ep-x-123456-pooler.aws.neon.tech/db?sslmode=require
DATABASE_URL_UNPOOLED=postgresql://...@ep-x-123456.aws.neon.tech/db?sslmode=require

If your app is on the direct URL, this one change is usually the entire fix. bun run verify tells you which one you are on.

Fix 2: shrink the per-instance pool to (about) one

Once the pooler is doing the pooling, a large client-side pool is pure waste: a serverless instance handles one request at a time, so connections two through five sit idle while still occupying pooler slots.

new Pool({ connectionString: databaseUrl(), max: 5, idleTimeoutMillis: 10_000 })

That is what getPool() in src/db/client.ts does, deliberately small, with a short idle timeout so a scaled-down instance stops holding slots. For ORMs that take the setting in the URL, connection_limit=1 is the equivalent.

Fix 3: prefer the HTTP driver

Neon's HTTP driver has no connection at all. getSql() sends one HTTPS request per statement; there is no socket to open, nothing to pool, nothing to exhaust, and on a cold instance it is the fastest option because there is no TLS handshake to a Postgres backend before your first row.

import { getSql } from "@/db";

const sql = getSql();
const users = await sql`select id, email from users where team_id = ${teamId} limit 50`;

Use it for reads, single-statement writes and returning inserts, which is most of an application. Reach for getPool() only when one request genuinely needs several statements to share a session.

When several writes must land together but none depends on the previous result, batchTransaction() gives you a real transaction in one round trip, still with no connection held:

await batchTransaction((sql) => [
  sql`insert into orders (id, total) values (${id}, ${total})`,
  sql`update inventory set stock = stock - 1 where sku = ${sku}`,
]);

Fix 4: stop leaking clients

Three leaks account for almost every case where the first three fixes did not help.

A client constructed per request. new Pool(...) or neon(...) inside a route handler creates a pool per invocation, and nothing ever closes it. Every client in this project is cached on globalThis for exactly this reason: Next.js also re-evaluates modules on every edit in dev, so without the cache a long dev session accumulates pools until the branch refuses new connections.

// don't
export async function GET() {
  const pool = new Pool({ connectionString: process.env.DATABASE_URL });
  ...
}

// do
import { getSql } from "@/db";

A transaction that awaits the network. A connection is held for the entire body of withTransaction. An HTTP call inside it (charging a card, calling an LLM) multiplies your connection hold time by the latency of someone else's service. Do the network call first, then open the transaction.

Long-running work in a request. A 30-second report query holds its connection for 30 seconds. Under concurrency that is your entire budget. Move it to a background job, or add set local statement_timeout so it fails fast instead of taking the pool down with it.

Diagnosing it while it is happening

-- how many backends, and doing what
select state, count(*) from pg_stat_activity
where datname = current_database() group by state;

-- the oldest offenders
select pid, state, now() - state_change as age, left(query, 80) as query
from pg_stat_activity
where datname = current_database() and state <> 'idle'
order by state_change asc limit 20;

idle in transaction rows are the smoking gun for a transaction that opened and never committed, usually an early return or a thrown error on a path that does not roll back. active rows with a large age are the long-query problem.

What not to do

  • Do not raise the connection limit as the first move. It buys you a larger number to exhaust and hides the leak.
  • Do not $disconnect() / end() after every request. A warm instance serves many requests; tearing the pool down makes the next request pay for TCP plus TLS again, typically 50-150 ms.
  • Do not add a read replica to fix writes. Replicas help read volume, not connection arithmetic: each replica has its own limit and your instances will open connections to both.

The shape of a healthy setup

App on the pooled URL, small per-instance pool, HTTP driver by default, interactive transactions rare and short, migrations on the direct URL, and select count(*) from pg_stat_activity staying flat while traffic climbs.