# 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.

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.

```ts
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.

```ts
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:

```ts
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.

```ts
// 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

```sql
-- 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.

---

Agentic Boilerplate: A Next.js repo your agent already knows. Free during launch, then $99 once.

- Site map for agents: https://agenticboilerplate.com/llms.txt
- Public API: https://agenticboilerplate.com/openapi.json
- Contact: agenticstudio@gmail.com
