PgBouncer vs Supavisor: PostgreSQL Connection Pooling — What Should You Use in the Serverless Era?
I still remember the day I first deployed a Next.js app on Vercel and ran into a too many connections error. Two years ago, on a side project, I was using PgBouncer Transaction mode and spent a long time unable to figure out why Prisma Prepared Statements were silently failing. Back then there wasn't a great solution, but since the Supavisor 1.0 GA release in 2024 and PgBouncer 1.21's Prepared Statement support, the criteria for choosing between them has fundamentally changed. Now is the perfect time to revisit this comparison.
In this post, we'll directly compare the structural differences between Supavisor and PgBouncer, real performance numbers, and which pooler fits your situation — with actual figures and configuration examples. Should you just always pick Supavisor because it's trending, or is PgBouncer still the better choice? The short answer: it depends. Let's find out on what.
Core Concepts
Why Do You Need a Connection Pooler?
PostgreSQL forks a separate OS process for each client connection. 100 connections means 100 processes; 1,000 connections means 1,000 processes. Each process consumes 5–10 MB of memory, and once context-switching overhead accumulates — you'll notice query response times visibly increasing once connection counts exceed 500. The DB server is already wasting resources before it even processes a single query.
Connection Pooler: Middleware that sits between your application and PostgreSQL, consolidating thousands of client connections into a small number of actual DB server connections. Clients connect to the pooler, and the pooler reuses its connections to the DB.
Serverless environments make this problem significantly worse. Traditional servers establish a connection once and keep reusing it, but Vercel Functions or AWS Lambda attempt a new connection on every function invocation. This is exactly why DB connections hit their limit so quickly when traffic spikes even slightly.
PgBouncer: 20 Years of Battle-Testing
PgBouncer is a lightweight, single-threaded, C-based pooler that appeared in 2007. It's extremely lean — using only about 2 MB of memory per 1,000 clients — and fast, with p99 latency under 4ms at 50 clients.
It supports three pooling modes, though Transaction mode is what most production deployments use.
| Mode | Connection returned when | Characteristics |
|---|---|---|
| Transaction | Transaction completes | Most efficient; session-state-dependent features restricted |
| Session | Client disconnects | No feature restrictions; lower savings |
| Statement | Each query completes | Multi-query transactions not possible |
There was one long-standing pain point in Transaction mode: you couldn't use Prepared Statements. This was finally resolved in PgBouncer 1.21 (2023) with the max_prepared_statements setting.
# pgbouncer.ini
[pgbouncer]
pool_mode = transaction
max_prepared_statements = 200
# Consider scram-sha-256 for production
auth_type = md5Prepared Statement: A feature that pre-parses and pre-compiles a SQL query so it can be re-executed with only the parameters changed. This reduces parsing overhead for repeated queries. Prisma ORM uses this approach by default.
That said, temporary tables (TEMP TABLE), Advisory Locks, and session variables set via SET commands are still incompatible with Transaction mode. If your app uses these features, Session mode or direct connections are the better choice.
Supavisor: Cloud-Native by Design
Supavisor is a PostgreSQL connection pooler that Supabase built from scratch in Elixir in 2023. The obvious question is "why build a new one when PgBouncer works fine?" — but the design goals are fundamentally different.
PgBouncer was designed as a sidecar process for a single DB instance. Supavisor was built from the ground up for multi-tenant SaaS infrastructure that pools connections across thousands of Postgres instances simultaneously from a single cluster.
Based on Supabase's own benchmarks, a single 64-core instance handles 500,000 connections, and two instances handle 1 million. Real-world production numbers will vary significantly depending on hardware specs and query patterns, but the point is clear: it sidesteps PgBouncer's single-threaded CPU saturation problem with an entirely different approach.
BEAM VM: The runtime for the Erlang ecosystem. It can concurrently run millions of ultra-lightweight virtual processes (not OS threads), communicating via message passing in the Actor model (Actor model = a concurrency pattern where independent processes with their own state communicate only through messages). Elixir runs on top of this, which is why Supavisor can handle enormous numbers of connections simultaneously.
Read replica routing is another standout feature. Supavisor parses queries and automatically distributes SELECT statements randomly across all registered replicas, while routing INSERT/UPDATE/DELETE to the primary. PgBouncer has no equivalent — achieving this manually required separately configuring HAProxy or pgpool-II.
Practical Application
Now that we have the concepts down, let's look at how to configure each option for specific real-world scenarios.
Example 1: Next.js + Prisma + Vercel Serverless Deployment
This is the most common pattern you'll encounter in serverless environments. Prisma uses Prepared Statements by default, and Vercel functions attempt a new connection on every invocation — without a pooler, DB connections saturate quickly.
If you're using Supabase, the standard approach is to configure two separate URLs.
# .env
# General queries: Supavisor Transaction mode (port 6543)
DATABASE_URL="postgresql://user:pass@db.xxx.supabase.co:6543/postgres?pgbouncer=true"
# Direct connection for migrations (port 5432)
DIRECT_URL="postgresql://user:pass@db.xxx.supabase.co:5432/postgres"// prisma/schema.prisma
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
directUrl = env("DIRECT_URL")
}| Setting | Purpose | Reason |
|---|---|---|
DATABASE_URL (port 6543) |
Runtime queries | Reuses connections through the pooler |
DIRECT_URL (port 5432) |
Migrations | ALTER TABLE and similar require a maintained session |
?pgbouncer=true |
Prisma compatibility parameter | Forces Prisma to use inline parameters instead of server-side Prepared Statements |
Omitting the ?pgbouncer=true parameter can cause intermittent errors as Prisma tries to use Prepared Statements. While Supavisor 1.0+ supports Named Prepared Statements, it's safer to include this parameter for connections going through the pooler.
Example 2: Traditional Server App (Rails / Django / Spring)
For apps with stable traffic and connection counts in the hundreds to low thousands, PgBouncer may still be the better choice. The configuration is simpler, latency is lower, and there are 20 years of operational knowledge behind it.
# /etc/pgbouncer/pgbouncer.ini
[databases]
myapp = host=localhost port=5432 dbname=myapp
[pgbouncer]
listen_addr = 0.0.0.0
listen_port = 6432
# Strongly recommended: use scram-sha-256 in production (default in PostgreSQL 14+)
auth_type = md5
auth_file = /etc/pgbouncer/userlist.txt
pool_mode = transaction
max_client_conn = 1000
default_pool_size = 25
min_pool_size = 5
reserve_pool_size = 5
# PgBouncer 1.21+ Prepared Statement support
max_prepared_statements = 200
# Note: server_reset_query only runs in session mode
# It has no practical effect in transaction mode
server_reset_query = DISCARD ALL
log_connections = 1
log_disconnections = 1# Check current pool status
psql -h localhost -p 6432 -U pgbouncer pgbouncer -c "SHOW POOLS;"For Rails ActiveRecord or Django, since the app itself also manages a connection pool, it's good practice to calculate default_pool_size as number of app instances × app-level pool size. With 4 app servers each with a pool size of 5, default_pool_size = 20 is about right.
Example 3: Multi-Tenant SaaS + Read Replicas
At this point, the comparison table might make Supavisor look like the obvious winner — but in practice, it really shines in situations like multi-tenant architectures where its advantages are clear.
In a structure where each tenant has a separate Postgres instance, a single Supavisor cluster independently pools all tenant connections. The key benefit is that each tenant's pool is isolated, so one tenant's query storm doesn't impact other tenants.
Registering read replicas is also relatively straightforward. Register the replica information via the Supabase dashboard or API, and from that point on, SELECTs are automatically distributed across all replicas while writes are routed to the primary.
# Supavisor connection example in a multi-tenant environment
# Tenant A
TENANT_A_DB_URL="postgresql://tenant_a:pass@pooler.supabase.co:6543/tenant_a?pgbouncer=true"
# Tenant B
TENANT_B_DB_URL="postgresql://tenant_b:pass@pooler.supabase.co:6543/tenant_b?pgbouncer=true"
# For read-only queries (automatic replica distribution)
TENANT_A_READ_URL="postgresql://tenant_a:pass@pooler.supabase.co:6543/tenant_a?pgbouncer=true&read=true"To implement this with PgBouncer, you'd need a separate instance per tenant and additional HAProxy or pgpool-II configuration for read/write splitting. The operational complexity difference is substantial.
Pros and Cons Analysis
Advantages
PgBouncer
| Item | Details |
|---|---|
| Ultra-low latency | p99 < 4ms (at 50 clients, Tembo benchmark) |
| Minimal memory | ~2 MB per 1,000 clients |
| Operational maturity | 20 years of production validation since 2007; extensive operational knowledge |
| Simple configuration | Most settings handled with a single ini file |
| Universal compatibility | Works with all Postgres versions in all environments |
Supavisor
| Item | Details |
|---|---|
| Horizontal scaling | 500,000 connections per single instance per Supabase's own benchmarks |
| Built-in multi-tenancy | Per-tenant pool isolation; prevents connection exhaustion |
| Read replica load balancing | Automatic SELECT distribution; automatic primary routing for writes |
| Serverless optimized | Handles explosive connection bursts well |
| Named Prepared Statements | Supported since 1.0 |
Disadvantages and Caveats
PgBouncer
| Item | Details | Mitigation |
|---|---|---|
| Single-threaded bottleneck | Throughput limited when a single CPU core saturates | Configure HAProxy in front of multiple instances |
| No multi-tenancy | Requires a separate PgBouncer per DB instance | Independent deployment per instance |
| No read/write routing | No replica distribution capability | Consider PgCat or pgpool-II |
| Transaction mode feature restrictions | Temporary tables, Advisory Locks unavailable | Switch to Session mode or use direct connections |
Supavisor
| Item | Details | Mitigation |
|---|---|---|
| Higher latency | 80–160% higher than PgBouncer | Keep PgBouncer for latency-sensitive workloads |
| Complex self-hosting | Requires Elixir stack operational experience | Recommended to use Supabase managed service |
| Cannot run alongside PgBouncer | Connecting both poolers to the same DB doubles connections | Choose one only; switch by replacing the connection string |
TPS Benchmark Reference: Per the Tembo independent benchmark — PgBouncer ~44,000 TPS, Supavisor ~21,700 TPS, PgCat ~59,000 TPS. Choosing solely based on TPS is risky. Scalability, operational convenience, and feature support must all be considered together.
The Most Common Real-World Mistakes
-
Using Prepared Statements as-is in PgBouncer Transaction mode: I fell into this trap myself — not knowing that ORMs like Prisma and SQLAlchemy use Prepared Statements by default, connecting in Transaction mode causes intermittent errors. On PgBouncer < 1.21, switch to inline parameters at the ORM level using the
?pgbouncer=trueparameter, or upgrade and setmax_prepared_statements. -
Connecting both Supavisor and PgBouncer simultaneously: A mistake you see on Stack Overflow occasionally — during a migration to Supabase, leaving the existing PgBouncer connection string (port 6543) in place while also adding a Supavisor connection causes both poolers to connect to the same DB, doubling the connection count. When switching, always make sure only one remains.
-
Running migrations through the pooler connection: DDL statements like
ALTER TABLEandCREATE INDEX CONCURRENTLYrequire a maintained session. Running migrations through a Transaction mode pooler can cause mid-migration disconnections or locking issues. The recommended pattern is a dedicated direct connection for migrations, like Prisma'sDIRECT_URL.
Closing Thoughts
Pooler selection should be driven by your workload's connection patterns, scaling requirements, and your team's operational capabilities — not by trends.
The reason Supavisor attracted so much attention in 2023–2024 is that it coincided exactly with an explosion in serverless traffic patterns. However, if your team lacks experience operating an Elixir stack, self-hosting can turn out to be more operationally expensive than expected — unless you're using Supabase's managed service. On the other hand, for traditional server apps with stable traffic where latency matters, PgBouncer is still the better choice. If you're on Vercel/Lambda serverless, using Supabase, running a multi-tenant SaaS, or need read replica distribution, you'll naturally gravitate toward Supavisor.
Here are three steps you can take right now:
-
Assess your current connection state: Use the command below to check the number and state of active connections on your DB. If
idleconnections exceed half the total, a pooler will make a meaningful difference. Note: if you see a lot ofidle in transactionconnections, that may indicate application code that isn't properly closing transactions — it's worth inspecting your app code before introducing a pooler.sqlSELECT count(*), state FROM pg_stat_activity GROUP BY state; -
Classify your workload type: If you're in a serverless (Vercel/Lambda) environment or have a multi-tenant architecture, consider Supavisor first. For traditional server apps with stable traffic, look at PgBouncer Transaction mode first. If you're already on Supabase, Supavisor is provided by default — try switching to the port 6543 connection string right away.
-
Benchmark before and after with pgbench: Use the command below to measure TPS and latency before and after applying a pooler.
-c 50is the number of concurrent clients;-T 60is the duration in seconds. It's the fastest way to quantify the actual impact in your own environment.bashpgbench -h localhost -p 6432 -U myuser -T 60 -c 50 mydb
References
- Supavisor 1.0: a scalable connection pooler for Postgres | Supabase Blog
- Supavisor: Scaling Postgres to 1 Million Connections | Supabase Blog
- Benchmarking PostgreSQL connection poolers: PgBouncer, PgCat and Supavisor | Tembo
- Migrating from PgBouncer | Supavisor Official Docs
- GitHub - supabase/supavisor
- Prepared Statements in Transaction Mode for PgBouncer | Crunchy Data
- PostgreSQL Connection Pooling: PgBouncer, Supavisor & Built-In | DEV Community
- How we fixed Postgres connection pooling on serverless with PgDog | Circleback