Connection pooling is the setting nobody reads about until Postgres starts rejecting logins. On serverless infrastructure — Vercel functions, AWS Lambda, Supabase Edge Functions — every cold invocation can try to open its own connection to Postgres, and Postgres was never built to hand out thousands of them. Fix it wrong and you trade one outage for a slower, harder-to-debug one three weeks later.
Key Takeaways
- Postgres connections are expensive processes, not lightweight sockets. A default install caps out around 100, and serverless traffic patterns can burn through that in seconds.
- PgBouncer's transaction mode is what actually saves you at scale, but it silently breaks session-level features: prepared statements, SET, temporary tables, LISTEN/NOTIFY.
- Supabase's "shared pooler" isn't PgBouncer at all. It's Supavisor, a separate project, and that distinction matters the first time its behavior surprises you.
- PgBouncer 1.21 added
max_prepared_statements, which quietly undid a piece of advice everyone had memorized: that transaction-mode pooling can never support named prepared statements. - Neon bundles PgBouncer by default and caps pooled connections at 10,000 clients funneled into a server-side pool sized at 90% of your compute's
max_connections. - Most "serverless needs a thousand connections" incidents are actually leaked connections from suspended function instances, not genuine concurrent demand.
Why Does Serverless Postgres Run Out of Connections So Fast?

Serverless Postgres runs out of connections because every function invocation can open a fresh one, and Postgres treats each connection as a costly forked process. A default max_connections of 100 sounds generous until a traffic spike fires two hundred concurrent Lambda invocations at once.
The counterintuitive part: the problem usually isn't concurrency at all. Vercel's own August 2025 write-up on the issue found that most of these incidents trace back to suspended function instances that never got the chance to close their connections cleanly, because the idle-cleanup timer that would normally fire simply doesn't run while an instance is frozen mid-invocation. Deploy a new version of your app, and every old instance can leak its open connection on the way out, all at once, right when your dashboard already looks bad for unrelated reasons.
Picture the actual failure. Wednesday, 11pm, a routine deploy goes out. Within ninety seconds, error logs fill with FATAL: too many connections for role, and the on-call engineer's first instinct is to blame the new code, not the fifty stale connections the old deployment left behind. You've probably had a version of this night. The fix that finally works has nothing to do with the code that shipped.
PgBouncer Transaction Mode vs Session Mode: What Actually Breaks

Transaction mode is the setting that makes pooling actually work at serverless scale, and it works by handing a Postgres connection to a client only for the duration of a single transaction, then returning it to the pool immediately. Session mode, by contrast, holds a dedicated backend connection for the life of the client's connection, which defeats the entire point of pooling under bursty serverless load.
The cost of transaction mode is that anything relying on session state stops working the way you expect. Named prepared statements, SET commands issued outside a transaction, temporary tables, advisory locks held across statements — all of it assumes the same backend connection persists between calls, and in transaction mode it doesn't. If you've ever debugged a prepared statement "s0" already exists error at 1am, you already know the fix isn't obvious from the error message itself.
Supabase's pooler exposes this split as two ports: 5432 for session mode, 6543 for transaction mode. As of February 2025, Supabase's Supavisor deprecated session mode on port 6543 entirely, so the two modes now map cleanly to two distinct ports instead of overlapping. Pick 6543 for anything running inside a serverless function; reach for 5432, or a genuinely direct connection, when you need session guarantees or you're running a migration.
Supabase's Pooler Isn't Actually PgBouncer

Supabase's default shared pooler is Supavisor, an Elixir-built connection pooler Supabase wrote and maintains, not PgBouncer. Only paying customers get a dedicated pooler that's actual PgBouncer, co-located with their database. The distinction rarely matters day to day. It matters enormously the first time you hit an edge case and go searching PgBouncer's changelog for a bug that doesn't exist there.
How Do You Configure Prisma for a Pooled Connection?
Configuring Prisma for a pooled connection means disabling named prepared statements, usually via a pgbouncer=true flag, then keeping a separate direct connection for schema changes. Runtime queries go through the pooled URL. Migrations, introspection, and prisma db push go through the direct one, because Prisma's schema engine needs guarantees a pooled connection can't offer.
That said, pgbouncer=true is no longer universally correct. Prisma's own docs now warn against setting it once the PgBouncer on the other end is version 1.21.0 or later with prepared statements configured server-side. Neon's changelog confirms it upgraded its managed PgBouncer to version 1.25.1 on February 27, 2026, so anyone still pasting pgbouncer=true into a Neon connection string out of habit may be solving a problem that version no longer has, and occasionally causing a new one. If you're setting up a fresh Next.js and Prisma stack on Postgres, check your pooler's actual PgBouncer version before you copy last year's Stack Overflow answer verbatim.
PgBouncer vs Supavisor vs Neon's Pooler: Side by Side
The three poolers you'll actually meet on a serverless SaaS stack behave differently enough that "just add a pooler" isn't a complete answer on its own.
| Pooler | What it is | Prepared statements | Where migrations should go |
|---|---|---|---|
| Standalone PgBouncer 1.21+ | The original C project, self-hosted or via a managed dedicated instance | Supported in transaction mode with max_prepared_statements set | Direct connection, bypassing the pooler |
| Supabase Supavisor | Elixir-built pooler, shared by default; dedicated PgBouncer on paid tiers only | Transaction mode (port 6543) does not support them | Session mode (port 5432) or a direct connection |
| Neon's built-in pooler | Managed PgBouncer, enabled by adding -pooler to the hostname | Depends on server-side max_prepared_statements config | The non-pooled hostname |
None of these is wrong. They're different trade-offs wearing the same "just enable pooling" marketing language, and the row you actually need depends on which platform is under your app. Neon's own pooling documentation puts hard numbers on its version: up to 10,000 client connections multiplexed into a server-side pool sized at 90% of your compute's max_connections, which is generous enough that most teams never touch the default.
Setting Up Postgres Connection Pooling Without Breaking Migrations
Get the order of operations right and this is a half-day task. Get it wrong and you find out during a production migration that your schema engine has been silently talking to the pooler the whole time.
- Identify your platform's pooled and direct connection strings separately. Supabase, Neon, and Vercel Postgres all expose both; don't assume one URL does both jobs.
- Point your application's runtime queries (via Prisma, Drizzle, or a raw client) at the pooled, transaction-mode connection string.
- Point your migration tool and any schema-introspection command at the direct, non-pooled connection string exclusively.
- Check your pooler's actual version before adding compatibility flags like
pgbouncer=true— on PgBouncer 1.21+ with prepared statements configured server-side, that flag can cause the exact error it was meant to prevent. - If you're on Vercel and traffic is bursty, wire up
attachDatabasePoolso idle connections get cleaned up before an instance suspends, instead of leaking until the platform kills it. - Load-test the deploy path specifically, not just steady-state traffic. Deployments are when every old instance leaks a connection at once, and that's the spike that actually pages someone.
The Prepared-Statement Advice That's Now Wrong
Every serverless Postgres thread from the last five years says the same thing: PgBouncer transaction mode can't do prepared statements, full stop, disable them everywhere. I repeated that line myself for a long time. Actually — that overstates it now. PgBouncer 1.21 shipped max_prepared_statements, and with it configured, transaction-mode pooling supports named prepared statements after all, which means the blanket advice to always pass pgbouncer=true is now, on a sufficiently new pooler, actively wrong advice.
This is the trap with infrastructure knowledge that gets passed around as folklore instead of read from a changelog. The rule was true when it was written. Nobody went back and updated the thread when the underlying software changed. On Pizzeria Bestek, the one production build I run on Supabase rather than Firebase, traffic sits at five to ten orders a day, which is nowhere near enough volume for any of this to matter in practice. That's not a failure of the setup. It's the honest reminder that pooling tuning is a scale problem, and copying it into a five-order-a-day app is just cargo-culting a Stripe-scale blog post onto a pizzeria's ordering page.
What Changed Recently in Postgres Connection Pooling?
The most consequential recent change is Vercel's Fluid Compute, which now closes idle serverless database connections automatically before an instance suspends. It ships as attachDatabasePool, a function built on waitUntil that keeps an instance alive just long enough to drain its connections first. That single change addresses the actual root cause behind most of the "serverless needs more connections" incidents teams have been fighting for years: not too many concurrent requests, but too many suspended instances holding connections open because nothing ever told them to let go.
Neon, meanwhile, is quietly deprecating the old pooler_mode and pgbouncer_settings fields on its Management API, with a sunset date after June 20, 2026. If your infrastructure-as-code touches those fields directly, that's worth checking before the sunset date arrives rather than after.
None of this pooling work replaces good architecture decisions elsewhere. Choosing Postgres in the first place, deciding how it fits into your broader SaaS MVP stack, and picking a serverless platform that's honest about its connection behavior all matter more than any single flag. But the flag still matters. Go check which PgBouncer version your pooler is actually running before you paste in pgbouncer=true out of habit.
