Every Payload + Next.js project we deploy to Vercel on Supabase Postgres hits the same three failures in the first month if the connection wiring is sloppy: the pooler refuses new clients at launch traffic, a Server Action throws `prepared statement "s_1" already exists` under concurrency, and `payload migrate` hangs in CI until the job times out. All three are the same bug — the wrong connection string in the wrong place. This is the wiring guide we now apply by default: what actually changed when Supabase moved from PgBouncer to Supavisor, the two-port rule, and the exact `DATABASE_URL` values we ship.
The outdated premise: Supabase pooled with PgBouncer, now it is Supavisor
If you search "does Supabase use PgBouncer" you get a 2021 answer. Supabase originally exposed PgBouncer on port 6543; since late 2023 they have been rolling out Supavisor, their own Elixir-based pooler, and new projects get Supavisor by default (see the Supabase connection pooling docs, updated through 2024–2025). Supavisor speaks the PgBouncer protocol, so connection strings and client behaviour are largely compatible — but it is not PgBouncer, and a few operational details (metrics, per-tenant pooling, IPv4 add-on behaviour) differ.
What did not change is the failure surface. Both poolers offer transaction mode and session mode; both cap client connections at a `pool_size` you configure per project; both will happily let a serverless runtime open more connections than Postgres can serve. The pooler swap is a vendor detail. The wiring discipline is the actual work.
The two-port rule: 5432 direct vs 6543 pooler
Supabase exposes two endpoints that matter for a Payload deploy:
Port 5432 (direct) — a straight line to Postgres. Full feature set: prepared statements, session state, advisory locks, LISTEN/NOTIFY. Limited by Postgres `max_connections` (plan-dependent, often 60–200).
Port 6543 (pooler, transaction mode default) — Supavisor multiplexes many client connections onto a small pool of server connections. Built for serverless concurrency, but transaction mode breaks anything that assumes session affinity.
Session mode (port 5432 on the pooler hostname in some regions, or a separate config) — behaves like a persistent connection through the pooler. Rarely what you want from Vercel.
The rule we enforce on every project: runtime traffic goes through the pooler; anything that migrates, seeds, or introspects the schema goes direct. Payload's `payload migrate`, `drizzle-kit push`, seed scripts, and any `pg_dump` in CI all use the direct connection. The Next.js app serving requests uses the pooler. Two environment variables, never one.
Transaction mode: what it breaks and what Payload actually needs
Transaction mode returns the server connection to the pool after every transaction. Anything that lives outside a transaction dies silently or loudly:
Named prepared statements — the classic `prepared statement already exists` error. The client prepares on one server connection, the next query lands on another that already has a statement with that name (or none).
SET SESSION / session-level config — gone after the transaction commits.
Advisory locks held outside a transaction — unreliable; Payload's migration lock is one reason migrations must go direct.
LISTEN/NOTIFY — requires session affinity; do not run realtime listeners through transaction mode.
The good news: Payload's Postgres adapter (Drizzle under the hood) issues ordinary parameterized queries at request time and does not need session state for normal CRUD, Local API calls, or Server Actions. It runs fine in transaction mode if the driver is told not to prepare statements. With `postgres.js` (Payload's default driver) that means `prepare: false`; with a `pg`-style URL it means the `pgbouncer=true` compatibility flag, which Prisma-style clients also honour.
// payload.config.ts — runtime uses the pooler, migrations use direct
import { buildConfig } from 'payload'
import { postgresAdapter } from '@payloadcms/db-postgres'
export default buildConfig({
// ...collections, plugins
db: postgresAdapter({
pool: {
// POOLED url: port 6543, transaction mode, prepare disabled
connectionString: process.env.DATABASE_URL,
max: 1, // per-function-instance cap — see the serverless math below
},
// Payload runs migrations through this same adapter when you call
// `payload migrate` — so the CLI must be given the DIRECT url instead.
// We do that with an env swap in package.json, not a second config.
migrationDir: 'src/migrations',
}),
}){
"scripts": {
"migrate": "cross-env DATABASE_URL=$DIRECT_DATABASE_URL payload migrate",
"migrate:status": "cross-env DATABASE_URL=$DIRECT_DATABASE_URL payload migrate:status",
"dev": "next dev",
"build": "next build"
}
}The env swap in `package.json` is deliberate. We tried maintaining a second Payload config for migrations (`payload.config.migrate.ts` pointing at the direct URL) on two projects in 2024 and abandoned it — the configs drifted, and one deploy ran migrations against the pooled URL anyway because someone exported the wrong env in CI. One config, one env swap, no drift.
The serverless math: Vercel concurrency multiplies your pool size
A managed pooler does not save you from a careless connection string. The arithmetic: Vercel function instances × pool `max` per instance = worst-case client connections to Supavisor. Supavisor then maps those onto its own `pool_size` of real Postgres connections — and if client demand exceeds that, requests queue and you see `connection acquire timeout` or 500s under burst.
Concrete numbers from a recent Payload + Supabase deploy: marketing site plus Payload admin, ~40 concurrent function instances at peak on Vercel's default settings. With `max: 5` per instance that is 200 potential client connections against a Supavisor `pool_size` of 15 on the project's tier — requests queued, p95 API latency went from ~180ms to 4s+ during a launch email. Dropping the per-instance pool to `max: 1` (40 worst-case clients) and raising the Supavisor pool size to 60 brought p95 back under 250ms. The fix was arithmetic, not infrastructure.
The connection strings we ship, annotated
// .env (Vercel project env vars)
// 1. Runtime — pooled, transaction mode, port 6543
DATABASE_URL="postgresql://postgres.abc123:[password]@aws-0-eu-central-1.pooler.supabase.com:6543/postgres?pgbouncer=true&connection_limit=1&statement_timeout=8000&sslmode=require"
// pgbouncer=true -> tells compatible clients to disable prepared statements
// connection_limit=1 -> one server connection per function instance (see math above)
// statement_timeout=8s -> a runaway query dies before Vercel's 10s function timeout,
// so the error surfaces as a query timeout, not a frozen lambda
// sslmode=require -> non-negotiable through the pooler
// 2. Migrations / seeds / introspection — direct, port 5432
DIRECT_DATABASE_URL="postgresql://postgres:[password]@db.abc123.supabase.co:5432/postgres?sslmode=require"
// No pgbouncer flag. Prepared statements and advisory locks work here,
// which is exactly what Payload's migration runner needs.Two defaults we always override: `statement_timeout` (Supabase's default is generous enough to outlive the function that issued the query — pointless work) and the per-instance pool cap. Everything else we leave at Supabase's defaults.
If you are still choosing where the Postgres lives — Supabase vs Neon vs self-managed PgBouncer on a VM — we ran that comparison separately: Postgres Connection Pooling on Serverless: The PgBouncer vs Supabase vs Neon Reality Check.
Migrations through the pooler: the CI hang we now design around
The failure mode, concretely: GitHub Actions runs `payload migrate` against the pooled URL. Payload takes an advisory lock to serialise migrations; in transaction mode the lock's connection is returned to the pool mid-run, the next statement lands on a different server connection, and the migration either hangs waiting on a lock it cannot see or fails with a lock-not-held error. The job burns 10 minutes of CI and exits with a timeout that points at nothing.
The split we enforce: migrate on direct, serve on pooler, and never let the two share an env var. In CI we also run `payload migrate:status` against the direct URL as a pre-deploy gate — if migrations are pending, the deploy job runs them first, then Vercel promotes. This removes an entire bug class: the half-migrated deploy where the new code expects a column the migration never applied.
When to bypass Supabase pooling entirely
Three workloads do not belong behind Supavisor's transaction mode at all:
BullMQ / queue workers — long-lived processes with persistent connections; give them the direct URL and their own small pool (5–10), ideally on a sidecar (Railway, Fly, a Hetzner box) so their connection budget is separate from Vercel's.
Bulk catalog imports — a 2,000-SKU Medusa or Payload seed holds connections for minutes; run it direct with a raised `statement_timeout`, never through the pooler where it starves runtime traffic.
Anything LISTEN/NOTIFY or realtime-adjacent — session affinity required, transaction mode incompatible by design.
What we monitor after wiring
Two alerts catch pool exhaustion before users do. First, a scheduled query against `pg_stat_activity` on the direct connection — alert when `count(*) > 0.8 * max_connections`. Second, Supavisor's own metrics in the Supabase dashboard: watch client connections vs pool size; when the client line pins against the pool ceiling for more than a few minutes, you are queueing. On the Vercel side, a spike in function duration with flat application logs is the same signal from the other direction — requests waiting on a connection look exactly like slow code until you check the pool.
This wiring is part of the default deployment shape on every Payload project we take to production. See how we ship Payload CMS platforms.
What we ship by default on every Payload + Supabase + Vercel project: two env vars (pooled runtime, direct migrations), `prepare` disabled and `max: 1` on the runtime pool, `statement_timeout` under the function limit, migrations as a CI gate on the direct connection, and the two pool alerts wired before launch. It is a day-one checklist now — it has prevented every one of the three failure modes above on every deploy since. If your Payload deploy is queuing behind its own pooler, tell us what you are wiring up.