# Prisma on serverless: why you need a pooler URL and a direct URL

> Each serverless instance opens its own Postgres pool, so traffic exhausts connections. Route runtime queries through a pooler and keep a direct URL for migrations.

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 `-pooler` in it:
  `ep-cool-name-123456-pooler.eu-central-1.aws.neon.tech`.
- **Supabase**: Supavisor, on port `6543` for transaction mode; the direct
  connection is port `5432`.
- **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`.

```ts
// src/db/driver.ts: the app, pooled
export function createAdapter() {
  return new PrismaPg({ connectionString: process.env.DATABASE_URL, max: 5 });
}
```

```ts
// prisma.config.ts: the CLI, direct
export default defineConfig({
  schema: "prisma/schema.prisma",
  datasource: { url: process.env.DIRECT_URL },
});
```

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

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

```sql
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 status` succeeds, 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_URL` migrates
  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.

---

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
