Skip to content

Cloud & Infrastructure · Database

Database Connection Pooling: PgBouncer vs Supabase vs Prisma

Connection exhaustion is a common production failure at scale. Here's how pooling actually works, which tool fits which setup, and the config decisions that matter.

Anurag Verma

Anurag Verma

8 min read

Database Connection Pooling in 2026

Sponsored

Share

“Too many connections” is one of the more demoralizing production errors to debug. Your database is running. Your app is running. Traffic is normal. And yet PostgreSQL is refusing new connections because you’ve hit the limit. Every new request fails while existing connections sit idle waiting for queries that will arrive seconds from now.

This is a connection pooling problem, and it’s nearly universal for any app that scales beyond a handful of instances. PostgreSQL has a hard maximum on concurrent connections, and in a serverless or container environment where hundreds of function instances each want their own connection, you hit that ceiling fast.

Why PostgreSQL Connections Are Expensive

PostgreSQL creates a new OS process for each client connection. This is the “process-per-connection” model, which has advantages for isolation but means each connection consumes real resources: around 5-10MB of memory per connection, a forked process, and a file descriptor.

A medium-sized database might set max_connections = 200. An application with 20 container replicas, each maintaining a pool of 10 connections, uses all of them before you’ve deployed the third version of your app that day. Add a staging environment, a migration job, and a few long-running background workers, and 200 fills up quickly.

The solution isn’t to increase max_connections indefinitely. Each connection has overhead even when idle, and database performance degrades before you hit the absolute maximum.

The Three Models

Application-level pooling runs inside your application process. Most database drivers and ORMs support this: Prisma’s built-in pool, SQLAlchemy’s pool, pg for Node.js with a Pool class. Multiple queries from within one app instance share a small set of actual database connections.

// Node.js: pg Pool
import { Pool } from 'pg'

const pool = new Pool({
  connectionString: process.env.DATABASE_URL,
  max: 20,        // max connections per pool instance
  idleTimeoutMillis: 30000,
  connectionTimeoutMillis: 2000,
})

// Queries acquire a connection, run, release it
const result = await pool.query('SELECT * FROM users WHERE id = $1', [userId])

Application-level pooling works for traditional long-running servers where each server process maintains its own pool. It breaks down for serverless functions and short-lived containers, where each execution creates a fresh pool that starts empty and tears down immediately, the same lifecycle issue covered in serverless cold starts.

Proxy-level pooling runs as a sidecar or standalone service between your application and the database. PgBouncer is the standard here. Your application connects to PgBouncer as if it’s the database, and PgBouncer multiplexes those connections onto a smaller set of real database connections.

Managed pooling is the proxy model packaged as a service. Supabase Supavisor, Prisma Accelerate, Neon’s built-in pooler, and RDS Proxy fall here. You configure a connection string that points to the pooler rather than the database directly.

PgBouncer: The Standard

PgBouncer is open-source, lightweight, and has been in production at scale for over a decade. It runs as a TCP proxy and supports three transaction modes:

Session mode: Each client connection maps to one server connection for the session’s lifetime. The simplest model, compatible with everything, but doesn’t reduce the connection count much.

Transaction mode: A server connection is held only during a transaction. Between transactions, it returns to the pool. This is the mode that actually solves the “too many connections” problem, and it’s what most people mean when they talk about connection pooling.

Statement mode: A server connection is released after each statement. The most aggressive mode, but incompatible with prepared statements, LISTEN, and a few other PostgreSQL features.

For most web applications, transaction mode is the right setting. The gotcha: some PostgreSQL features don’t work in transaction mode. Specifically, session-level variables (SET LOCAL), advisory locks, and LISTEN/NOTIFY require session mode. If your app uses these, you’ll need to route those specific connections around the pooler.

A minimal PgBouncer config:

# pgbouncer.ini
[databases]
myapp = host=postgres-host dbname=myapp

[pgbouncer]
listen_port = 6432
listen_addr = 0.0.0.0
auth_type = md5
auth_file = /etc/pgbouncer/userlist.txt
pool_mode = transaction

; Maximum server-side connections
max_server_connections = 50

; Per-user pool size
default_pool_size = 20
min_pool_size = 5

; Kill idle connections after this many seconds
server_idle_timeout = 600

In Docker Compose or Kubernetes, PgBouncer runs as a sidecar or a dedicated pod that all application instances point to.

# docker-compose.yml
services:
  pgbouncer:
    image: bitnami/pgbouncer:latest
    environment:
      - POSTGRESQL_HOST=postgres
      - POSTGRESQL_PORT=5432
      - POSTGRESQL_USERNAME=myapp
      - POSTGRESQL_PASSWORD=${DB_PASSWORD}
      - POSTGRESQL_DATABASE=myapp
      - PGBOUNCER_PORT=6432
      - PGBOUNCER_POOL_MODE=transaction
      - PGBOUNCER_MAX_SERVER_CONNECTIONS=50
    ports:
      - "6432:6432"
    depends_on:
      - postgres

Supabase Supavisor

Supabase replaced PgBouncer with Supavisor, their own pooler written in Elixir. If you’re hosting on Supabase, you get this automatically. Your project has two connection strings: one direct to Postgres and one through the pooler.

The pooler string is what you use in serverless environments:

# Direct connection (for migrations, long-running servers)
postgresql://user:password@db.project.supabase.co:5432/postgres

# Pooled connection (for Vercel functions, Cloudflare Workers, etc.)
postgresql://user:password@aws-0-us-east-1.pooler.supabase.com:6543/postgres

The key difference from PgBouncer: Supavisor supports pgbouncer=true in the connection string for Prisma ORM compatibility. Prisma uses prepared statements by default, which don’t work in transaction mode. Adding ?pgbouncer=true to the connection string tells Prisma to use unprepared queries.

// prisma/.env for Supabase
DATABASE_URL="postgresql://user:pass@pooler.supabase.com:6543/postgres?pgbouncer=true"
DIRECT_URL="postgresql://user:pass@db.project.supabase.co:5432/postgres"
// schema.prisma
datasource db {
  provider  = "postgresql"
  url       = env("DATABASE_URL")
  directUrl = env("DIRECT_URL")  // used for migrations
}

Prisma Accelerate

Prisma Accelerate is a connection pooler plus edge cache, running as a global distributed service. It solves the same connection exhaustion problem but adds query result caching at the edge, which can eliminate database round-trips entirely for frequently-read data.

import { PrismaClient } from '@prisma/client/edge'
import { withAccelerate } from '@prisma/extension-accelerate'

const prisma = new PrismaClient().$extends(withAccelerate())

// Standard query (uses pooling only)
const user = await prisma.user.findUnique({ where: { id: userId } })

// With edge caching: cached for 60 seconds globally
const products = await prisma.product.findMany({
  where: { featured: true },
  cacheStrategy: { ttl: 60, swr: 120 }
})

The swr parameter (stale-while-revalidate) lets Accelerate serve cached data while refreshing in the background, avoiding cache misses causing latency spikes.

The tradeoff: Accelerate is a paid managed service, and caching introduces staleness that you have to design around. It’s well-suited for apps where some data is acceptable to be slightly stale (product catalogs, public content) but shouldn’t be used for anything where real-time consistency matters (account balances, inventory that must be exact).

AWS RDS Proxy

For AWS-native setups, RDS Proxy runs between your application and an RDS or Aurora database. It integrates with IAM for authentication, which means no password rotation headaches. Your Lambda or ECS task uses its IAM role to authenticate to the proxy.

# Python with IAM auth for RDS Proxy
import boto3
import psycopg2

def get_auth_token():
    client = boto3.client('rds', region_name='us-east-1')
    return client.generate_db_auth_token(
        DBHostname='myapp.proxy-xxxx.us-east-1.rds.amazonaws.com',
        Port=5432,
        DBUsername='myapp_user'
    )

conn = psycopg2.connect(
    host='myapp.proxy-xxxx.us-east-1.rds.amazonaws.com',
    port=5432,
    database='myapp',
    user='myapp_user',
    password=get_auth_token(),
    sslmode='require'
)

RDS Proxy’s limitation is cost. It’s priced per vCPU of the underlying database, which adds 10-30% to your RDS bill. For Lambda-heavy workloads where connection exhaustion is a real problem, the cost is usually worth it. For applications with predictable server-based deployments, PgBouncer is cheaper, and if you’re evaluating edge-native alternatives entirely, Turso’s libSQL sidesteps the connection-pooling problem differently by moving the database to the edge itself.

Which Tool to Use

SetupRecommendation
Serverless functions (Vercel, Netlify, Cloudflare) on SupabaseSupabase Supavisor (built in)
Serverless functions on AWSRDS Proxy or Prisma Accelerate
Self-hosted or Docker ComposePgBouncer sidecar
KubernetesPgBouncer as a Deployment
Prisma + edge functionsPrisma Accelerate
Need query caching tooPrisma Accelerate
Tight budget, AWSRDS Proxy (if Lambda-heavy) or PgBouncer on EC2

Connection Pooling Configuration Mistakes

Setting max_connections too high. Increasing PostgreSQL’s connection limit beyond what the instance can handle just moves the failure point. Each connection consumes shared memory; at some point you’re paging, not pooling.

Using the direct connection string in serverless. The most common mistake. Every new Lambda invocation opens a fresh connection to the database, hits the limit, and returns “too many connections.” Use the pooler string in serverless, the direct string only for migrations.

Not monitoring pool wait time. When all connections are in use, new queries queue. If your queries are spending significant time waiting for a connection rather than actually executing, your pool is too small. Instrument this.

Ignoring prepared statement compatibility. If you use PgBouncer in transaction mode and your ORM uses prepared statements (Prisma does by default, SQLAlchemy can), you’ll see errors about unknown prepared statements. Configure your ORM to use unprepared queries when behind a transaction-mode pooler.

# SQLAlchemy: disable prepared statements for PgBouncer
engine = create_engine(
    DATABASE_URL,
    pool_pre_ping=True,
    execution_options={"compiled_cache": None}  # disables prepared statement caching
)

Connection pooling is not glamorous infrastructure work. It’s also the kind of thing that causes a Saturday night at 11pm debugging an outage that 30 minutes of configuration upfront would have prevented, not unlike the coordination bugs covered in distributed locks with Redis and ZooKeeper. Set it up before you need it.

Frequently asked questions

Why does PostgreSQL run out of connections so easily?
PostgreSQL creates a full OS process per client connection, consuming roughly 5-10MB of memory and a file descriptor each, even when idle. A typical max_connections=200 setting fills up quickly once you have multiple container replicas each holding their own pool, plus a staging environment, migration jobs, and background workers all competing for the same fixed ceiling. Raising max_connections indefinitely isn't the fix, since performance degrades from the overhead before you even hit the hard limit.
What's the difference between PgBouncer's session, transaction, and statement pooling modes?
Session mode holds one server connection per client for the whole session, which is compatible with everything but barely reduces connection count. Transaction mode releases the server connection back to the pool between transactions, which is what actually solves connection exhaustion for most web apps, but breaks session-level features like SET LOCAL, advisory locks, and LISTEN/NOTIFY. Statement mode releases the connection after every single statement, the most aggressive option, but it's incompatible with prepared statements and a few other Postgres features.
Why does Prisma need pgbouncer=true in the connection string?
Prisma uses prepared statements by default, and prepared statements don't survive PgBouncer's transaction pooling mode because the underlying server connection can change between statements. Adding ?pgbouncer=true to the connection string (supported by Supabase's Supavisor pooler, among others) tells Prisma to fall back to unprepared queries, which work correctly behind a transaction-mode pooler.
Should I use PgBouncer, Supabase Supavisor, Prisma Accelerate, or RDS Proxy?
It depends on your hosting setup. Use Supabase's built-in Supavisor if you're already on Supabase with serverless functions. Use RDS Proxy or Prisma Accelerate for serverless functions on AWS, RDS Proxy if you want IAM-based auth and don't need caching, Accelerate if you want edge query caching too. Use PgBouncer as a sidecar or Kubernetes Deployment for self-hosted or Docker Compose setups, since it's cheaper than RDS Proxy for predictable, non-Lambda-heavy workloads.

Sponsored

Sponsored

Discussion

Join the conversation.

Comments are powered by GitHub Discussions. Sign in with your GitHub account to leave a comment.

Sponsored