Your app works locally and in preview. Then real traffic arrives and the logs fill up with:
Error: Timed out fetching a new connection from the connection pool.
(More info: http://pris.ly/d/connection-pool)
or, straight from Postgres:
FATAL: sorry, too many clients already
FATAL: remaining connection slots are reserved for non-replication superuser connections
The queries are the same ones that were fine ten minutes ago. The problem is not the query. It is arithmetic.
Why serverless breaks Postgres connection math
A traditional Node server is one process with one pool. Ten connections total, no matter how much traffic arrives: requests queue on the pool.
Serverless inverts that. Each concurrent request can land on its own instance,
and each instance evaluates your module graph, constructs its own
PrismaClient, and opens its own pool. Traffic does not queue on a pool; it
creates pools.
Do the arithmetic with a default pool. Since Prisma 7 the pool belongs to the
driver adapter, and node-postgres (PrismaPg) allows 10 connections per pool.
Fifty concurrent invocations is up to 500 connections. Neon's free tier allows
about 100 direct connections; a Supabase micro instance allows 60; a small
self-managed Postgres defaults to 100 and needs some of those for maintenance.
You do not need much traffic to lose. A burst of a hundred requests to a page that was previously cached is enough.
The fix: a connection pooler in front of Postgres
A pooler (PgBouncer, or your provider's built-in equivalent) accepts a large number of client connections and multiplexes them onto a small number of real Postgres connections. In transaction mode, a backend connection is held only for the duration of a transaction, then handed to the next client. Thousands of serverless instances share a few dozen real sessions.
Every managed Postgres aimed at serverless ships one:
- Neon: the pooled host has
-poolerin it:ep-cool-name-123456-pooler.eu-central-1.aws.neon.tech. - Supabase: Supavisor, on port
6543for transaction mode; the direct connection is port5432. - Self-managed: run PgBouncer yourself in transaction pooling mode.
Why you also need a direct URL
Here is the part that bites people who configure only the pooler: Prisma Migrate cannot run through a transaction pooler.
Migrations take a Postgres advisory lock so two deploys cannot apply the same migration at once, and they issue DDL across multiple statements in one session. A transaction pooler gives a different backend connection per transaction, so the lock is taken on one connection and looked for on another. The migration hangs, times out, or reports a mystifying lock error.
Prisma 7 keeps the two apart by construction. The app never reads a URL from
the schema: it connects through the driver adapter, on the pooled string.
The CLI (migrate, db push, studio) reads the direct string from
prisma.config.ts.
// src/db/driver.ts: the app, pooled
export function createAdapter() {
return new PrismaPg({ connectionString: process.env.DATABASE_URL, max: 5 });
}
// prisma.config.ts: the CLI, direct
export default defineConfig({
schema: "prisma/schema.prisma",
datasource: { url: process.env.DIRECT_URL },
});
# .env.local, Neon shown; the shape is the same everywhere
DATABASE_URL="postgresql://user:pass@ep-x-123456-pooler.aws.neon.tech/db?sslmode=require"
DIRECT_URL="postgresql://user:pass@ep-x-123456.aws.neon.tech/db?sslmode=require"
If your database has no pooler at all, both are the same string. This repo's
prisma.config.ts falls back to DATABASE_URL (with the pooler host or port
rewritten away for Neon and Supabase) when no direct string is set, so a local
Postgres needs one variable.
The two pool settings that matter
Named prepared statements. A transaction pooler hands each transaction to
whichever backend is free, so a statement one instance prepared by name can
collide with another's: prepared statement "s0" already exists. Prisma 6
needed pgbouncer=true in the URL to stop that. Prisma 7's adapters send
unnamed statements by default (PrismaPg only names them if you pass a
statementNameGenerator), so leave that option out on a pooler, and drop
pgbouncer=true from old URLs: the adapter ignores it.
Pool size. Set max on the adapter, not connection_limit in the URL
(also a Prisma 6 engine setting the adapter ignores). Keep it small, a handful
per instance: the pooler is doing the pooling now. One small pool per instance
times many instances, multiplexed by the pooler, is the whole design. Set
connectionTimeoutMillis too: node-postgres waits forever for a free
connection by default, and a request that fails after ten seconds beats one
that hangs until the platform kills it.
Do not disconnect after every request
// don't
export async function GET() {
const rows = await prisma.user.findMany();
await prisma.$disconnect();
return Response.json(rows);
}
A warm instance serves many requests. Disconnecting throws away the pool and makes the next request pay for TCP setup plus a TLS handshake: often 50-150ms on a hosted database. Let the client live as long as the instance does.
Edge runtime is a different problem
A route on the edge runtime cannot open a TCP socket, so PrismaPg does not
work there. Run that route on the Node runtime (export const runtime =
"nodejs"), or use an adapter that speaks HTTP or WebSocket: @prisma/adapter-neon
with Neon's serverless driver, for example. Choose deliberately: PrismaNeonHttp
cannot run an interactive $transaction.
Checking your work
Watch the real connection count while you load the app:
select count(*) from pg_stat_activity where datname = current_database();
Against the pooled URL under load, that number should stay flat and small. Against a direct URL, it climbs with concurrency, which is exactly the failure you are trying to avoid.
Two more checks worth doing before you call it done:
prisma migrate statussucceeds, proof the direct string really is unpooled.- Your platform's environment variables contain both URLs, for every
environment. A preview deploy that inherits only
DATABASE_URLmigrates through a rewritten guess at best, and through the pooler at worst, and the error message will not mention the missing variable in an obvious way.