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.