Supabase Connection Pooling: PgBouncer on Vercel Serverless
Configure Supabase PgBouncer pooling for Vercel serverless — avoid connection exhaustion, choose pool modes, tune for production.
The most common production failure pattern for Next.js + Supabase apps on Vercel isn't a code bug — it's connection exhaustion. Your app works fine in development, handles moderate traffic in staging, then falls over under real load with too many connections errors.
This happens because each serverless function instance opens its own database connections. A warm instance reuses them across invocations, but under load Vercel runs many instances at once, their connections add up, and Postgres has a hard limit. A connection pooler is the solution, and Supabase ships two: Supavisor, the shared pooler on every plan, and PgBouncer, the dedicated pooler on paid plans. The dedicated pooler listens on db.[project-ref].supabase.co:6543, which is IPv6 unless the project has the IPv4 add-on, so from Vercel, an IPv4-only platform, the shared pooler is the one to use. This guide explains how to configure it correctly.
Estimated read time: 12 minutes
Prerequisites#
- Supabase project (any plan)
- Next.js app deployed or deploying to Vercel
- Basic understanding of environment variables and database connections
- Familiarity with Supabase client setup
Why Serverless Breaks Traditional Connection Management#
In a traditional Node.js server, you create a connection pool once at startup and reuse connections across requests. The pool might have 10–20 connections serving thousands of requests.
In serverless (Vercel Functions), there is no long-lived server you control. A warm instance keeps its global state, a client or pool included, across the invocations it serves, and on Fluid compute, the default for new Vercel projects, concurrent invocations share that instance (Vercel's connection pooling guide). But instances start and stop with traffic, so an application-level pool only ever caps one instance, never the whole deployment.
Postgres itself has a connection limit, and on Supabase it follows the compute size, not the plan (compute and disk limits):
- Nano (the Free plan) and Micro (the base instance on paid plans): 60 direct connections, 200 pooler clients
- Small: 90 direct connections, 400 pooler clients
- Medium: 120 direct connections, 600 pooler clients, and more on larger sizes
60 connections sounds like a lot until 60 serverless instances each hold one. Past the limit, new connections are refused.
A pooler sits between your application and Postgres, maintaining a small pool of actual Postgres connections and multiplexing many application connections through them. Supabase exposes two, and the port tells you which one you get: per the connection documentation, port 5432 reaches Postgres directly (or Supavisor in session mode) while port 6543 reaches PgBouncer on the dedicated pooler, or Supavisor in shared transaction mode. Most projects are on Supavisor without having chosen it.
Supabase's Two Connection Endpoints#
Every Supabase project has two ways to connect:
| Direct Connection | Supabase Pooler (transaction mode) | |
|---|---|---|
| Port | 5432 | 6543 |
| Use case | Migrations, long-lived servers | Serverless, short-lived connections |
| Prepared statements | Yes | No (transaction mode) |
| Connection limit | Database max connections (60 on Nano and Micro) | Pooler max clients (200 on Nano and Micro) |
Find both in your Supabase dashboard: click Connect. The same panel also lists the session pooler, port 5432 on the pooler host, which is the way to reach port 5432 from an IPv4-only platform: Supabase documents the direct connection as IPv6 unless the project has the IPv4 add-on, and lists Vercel among the platforms that only accept IPv4.
Configuring Your Environment Variables#
# .env.local
# For application queries (use this in your app)
DATABASE_URL="postgresql://postgres.[project-ref]:[password]@[pooler-host]:6543/postgres"
# For migrations only (session pooler, port 5432; reachable over IPv4)
DIRECT_URL="postgresql://postgres.[project-ref]:[password]@[pooler-host]:5432/postgres"
# or the direct connection, IPv6 unless you have the IPv4 add-on:
# DIRECT_URL="postgresql://postgres:[password]@db.[project-ref].supabase.co:5432/postgres"
# Supabase client (unchanged)
NEXT_PUBLIC_SUPABASE_URL="https://[project-ref].supabase.co"
NEXT_PUBLIC_SUPABASE_ANON_KEY="your-anon-key"The pooler URLs use the pooler host shown in the Connect panel (copy it rather than composing it: Supabase warns that it cannot be derived from your region name) with the postgres.[project-ref] user. The direct URL uses db.[project-ref].supabase.co with the plain postgres user. Ports and users differ between them — make sure you're using the right one.
Check the strings you just set#
Paste either value below — the pooled DATABASE_URL or the direct one — to confirm the host, port and pool mode it actually points at, and what Prisma, Drizzle or pg need on top of it. It runs in your browser; the password is masked in every result.
Runs entirely in your browser — nothing you paste is sent anywhere, and the password is masked in every result. Still cautious? Replace the password with any string containing the same special characters: the diagnosis only needs its shape.
Pool Modes: Transaction vs Session#
PgBouncer supports three modes, and Supabase's poolers expose two of them. For direct Postgres connections from Vercel functions, Supabase recommends transaction mode. Keep Prisma Migrate off it: through Supabase's transaction pooler, prisma migrate dev can stall after its loading lines, with no error and no timeout, as covered in Prisma Migrate Dev Hangs? Fix the Supabase Pooler Stall.
Transaction mode (port 6543):
- A connection is borrowed from the pool for the duration of one transaction
- Released back to the pool immediately after
COMMITorROLLBACK - Supports hundreds of concurrent app connections with a small Postgres pool
- Does NOT keep session state between transactions:
SET, session-level advisory locks,LISTEN,WITH HOLDcursors and temporary tables that outlive a transaction (PgBouncer feature map, Supabase's transaction mode limitations) - Prepared statements: Supabase's transaction mode does not support them; PgBouncer 1.21.0 and later supports protocol-level ones when
max_prepared_statementsis set, but never SQL-levelPREPARE
Session mode (port 5432 on the pooler host; a direct connection on 5432 behaves the same way for your client):
- One Postgres connection per client session
- Supports all Postgres features
- Not suitable for serverless — you're back to the original problem
For direct Postgres connections from serverless functions, use transaction mode.
Using the Pooler with the Supabase JS Client#
The Supabase JS client (@supabase/supabase-js) uses the REST API and Realtime, not a direct Postgres connection. It's not affected by PgBouncer configuration.
PgBouncer matters when you're using a direct Postgres client — typically with an ORM like Prisma or Drizzle, or with pg directly.
With Prisma#
// prisma/schema.prisma
datasource db {
provider = "postgresql"
url = env("DATABASE_URL") // pooler URL (port 6543)
directUrl = env("DIRECT_URL") // direct URL (port 5432) for migrations
}On Prisma 5 and 6, the CLI (prisma migrate, db push, db pull) uses directUrl and Prisma Client uses url for runtime queries. This is the correct setup for Vercel deployments. Prisma 7 rejects both fields in schema.prisma (error P1012): the CLI's URL goes in datasource.url of prisma.config.ts, as the Prisma 7 upgrade guide shows. If prisma migrate dev then stops because the role cannot create its shadow database, see Prisma Migrate P3014: permission denied to create database.
Prisma 5 and 6 turn off their prepared statements when the pooled URL ends in ?pgbouncer=true; Supabase's Prisma troubleshooting page says the flag "turns off prepared statements in Prisma". Add it to DATABASE_URL (…:6543/postgres?pgbouncer=true), not to DIRECT_URL. The client itself only needs the usual singleton:
// src/lib/prisma.ts
import { PrismaClient } from '@prisma/client'
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined
}
export const prisma =
globalForPrisma.prisma ??
new PrismaClient({
datasources: {
db: {
url: process.env.DATABASE_URL,
},
},
})
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prismaThe global singleton pattern prevents creating a new Prisma client on every hot-reload in development. The datasources option above is Prisma 5 and 6 syntax; on Prisma 7 the pooled string goes to a driver adapter instead, new PrismaClient({ adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL }) }), as in Prisma's PgBouncer page.
With Drizzle ORM#
// src/lib/db.ts
import { drizzle } from 'drizzle-orm/postgres-js'
import postgres from 'postgres'
// For serverless: use pooler URL, disable prepare
const client = postgres(process.env.DATABASE_URL!, {
prepare: false, // required for PgBouncer transaction mode
})
export const db = drizzle(client)The prepare: false option is critical. Without it, Drizzle will attempt to use prepared statements, which fail in transaction mode.
With the pg Package Directly#
// src/lib/db.ts
import { Pool } from 'pg'
// In serverless, a pool of 1 is often optimal
// The pooler handles the actual connection multiplexing
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
max: 1, // one connection per function instance
idleTimeoutMillis: 0,
connectionTimeoutMillis: 5000,
})
export default poolSetting max: 1 might seem counterintuitive, but it is what Supabase's connection guide prescribes for serverless: the client "is shared by every invocation on that warm instance, so this caps the instance, not the request", and the pool should grow only "when you have evidence that concurrent invocations on one instance are queuing for the connection". The pooler handles multiplexing across instances. Plain pool.query(text, values) calls are safe on port 6543: node-postgres only creates a prepared statement when a query config carries a name.
Monitoring Connection Usage#
Check your current connection count in Supabase:
-- Run in Supabase SQL editor
SELECT count(*) FROM pg_stat_activity;
-- See connections by state
SELECT state, count(*)
FROM pg_stat_activity
GROUP BY state;
-- See connections by application
SELECT application_name, count(*)
FROM pg_stat_activity
GROUP BY application_name
ORDER BY count DESC;Connections in idle state are normal for pooled or long-lived connections, as Supabase's list of connection states puts it. Connections in idle in transaction state are the warning sign: transactions that aren't being committed or rolled back.
Supabase also exposes connection metrics in the dashboard under Reports → Database.
Vercel-Specific Considerations#
Function concurrency: Vercel can run many function instances simultaneously. Each instance may hold a connection. The pooler absorbs this, but you should still set reasonable connection limits per instance.
Edge Runtime: If you're using Next.js Edge Runtime (export const runtime = 'edge'), you cannot use Node.js Postgres drivers. pg and postgres (Postgres.js) rely on TCP sockets, which Vercel Edge Runtime does not provide (Postgres.js's TCP-socket support is a Cloudflare Workers feature, not a Vercel Edge one). To query Supabase from an Edge function, use the Supabase JS client (@supabase/supabase-js) — it speaks HTTP to PostgREST, so it runs on any runtime. If you need raw SQL, move that route to the Node.js runtime (runtime = 'nodejs') and use the pooled connection string with pg/postgres. (Note: @neondatabase/serverless is Edge-compatible but built for Neon; with any other Postgres, Supabase included, it needs a WebSocket proxy you run yourself.)
Warm vs cold starts: On cold starts, a new connection must be established, so the first request also pays for the TCP, TLS and authentication handshake. Subsequent requests on a warm instance reuse the connection. This is normal behavior — don't try to pre-warm connections.
Connection string in environment variables: Never hardcode connection strings. Use Vercel's environment variable system and ensure DATABASE_URL is set for all environments (development, preview, production) with the appropriate values.
Common Pitfalls#
Using the direct connection URL in production. The direct URL (port 5432) is for migrations and admin tasks. Using it for application queries in serverless will exhaust connections under load.
Not setting prepare: false with Drizzle (Postgres.js), or using named queries with pg. Transaction mode doesn't support prepared statements. This causes cryptic errors that are hard to trace back to the connection mode.
Running migrations through the transaction pooler. Supabase sends migrations to the direct connection because "these are single sessions and Postgres native commands", and Prisma documents that its schema engine "does not support connection pooling with PgBouncer". Run migrations on port 5432: the session pooler from Vercel or CI, or the direct connection where IPv6 works.
Ignoring idle in transaction connections. These are connections that started a transaction but never committed. They hold a Postgres connection indefinitely. Always use try/catch/finally to ensure transactions are committed or rolled back.
Summary and Next Steps#
The setup is straightforward: use the transaction pooler URL (port 6543) for all application queries, port 5432 (the session pooler from Vercel, or the direct URL where IPv6 works) for migrations only, and disable prepared statements in your ORM. That's it.
The deeper lesson is that serverless multiplies connection holders: every running instance is a separate client. A transaction-mode pooler bridges that gap, but you need to understand what transaction mode trades away (session-level state and, on Supabase, prepared statements) to use it correctly.
Related reading:
- Deploying Next.js + Supabase to Production
- Database Design and Optimization for Next.js and Supabase Applications
Further Reading#
- Supabase debugging and DB operations hub
- Zero-Downtime Postgres Migrations on Supabase
- Next.js + Supabase: The Complete Resource Hub
Further reading: Supabase pgsodium_trg_mask_update: Fix Owner Error.
Frequently Asked Questions
Still stuck? Paste your error.
Paste the error message or stack trace — we check it against every fix and tool on the site.
One email a month — the latest explained errors
What the site published that month — mostly Next.js, Supabase and Node errors, explained. You confirm by email; unsubscribe any time in one click.
Related Guides
Supabase Postgres Functions & Triggers: Developer Guide
Complete guide to Postgres functions and triggers in Supabase. Learn PL/pgSQL, automated workflows, business logic, and database-level validation patterns.
Zero-Downtime Supabase Migrations: Expand/Contract (2026)
Ship Supabase schema changes while traffic flows: expand/contract steps, lock_timeout settings, batched backfills and RLS-safe rollouts wired into CI.
Testing Next.js + Supabase: Unit, Integration, RLS, E2E
Four layers that catch real bugs: Jest units, integration on local Supabase, pgTAP for RLS policies, Playwright E2E — plus the CI to run them all.

