Wise Hustlers — Digital Product & App Development Studio Logo
Get Consultation
By Wise Hustler Admin9/4/20268 min read

Connection Pooling in PostgreSQL: PgBouncer, Prisma, and What Breaks at Scale

Connection Pooling in PostgreSQL: PgBouncer, Prisma, and What Breaks at Scale

# Connection Pooling in PostgreSQL: PgBouncer, Prisma, and What Breaks at Scale

TL;DR: PgBouncer's transaction pooling mode is what lets thousands of app connections share a handful of real Postgres backends — but it silently breaks Prisma's prepared statements unless you set pgbouncer=true, and mismatched pool sizes between Postgres, PgBouncer, and Prisma's connection_limit are the most common cause of P2024 timeout errors in production.

Why Postgres connections are expensive in the first place

PostgreSQL handles concurrency with a process-per-connection model: every client connection gets its own OS process on the server, not a lightweight thread. max_connections defaults to 100, and each backend process carries a few megabytes of baseline memory overhead of its own (process stack, catalog cache, buffer pointers) — independent measurements land in the single-digit-MB range, though the exact figure depends on version, extensions loaded and workload — before work_mem is allocated per sort or hash operation on top of that.

That means a Postgres instance advertising "500 connections" isn't free — it's a standing memory and scheduler commitment, whether or not those connections are doing anything. Raise max_connections naively on an undersized instance and you trade query throughput for connection count: more idle backends means more context-switching overhead and less RAM left for shared_buffers and the OS page cache.

This is the actual reason connection pooling exists for Postgres specifically (MySQL's thread-per-connection model is cheaper, which is part of why pooling is less universally mandatory there). The fix isn't "more max_connections," it's fewer, reused, real connections in front of many more logical client connections.

PgBouncer: what it actually does

PgBouncer is a lightweight proxy that speaks the Postgres wire protocol on both sides — your app connects to it exactly like it would to Postgres, and it maintains a much smaller pool of real backend connections to the actual database. It's a single-threaded C process, and it remains the most widely deployed external pooler in the Postgres ecosystem.

The setting that determines everything about its behavior is pool_mode, configured per-database (or globally) in pgbouncer.ini:

[databases]
app_db = host=127.0.0.1 port=5432 dbname=app_production

[pgbouncer]
listen_port = 6432
listen_addr = *
auth_type = scram-sha-256
auth_file = /etc/pgbouncer/userlist.txt

pool_mode = transaction
max_client_conn = 2000
default_pool_size = 20
reserve_pool_size = 5
max_prepared_statements = 200
ModeBackend connection returned to poolSession state (SET, temp tables)Multi-statement transactionsTypical use
sessionAfter client disconnectsPreservedYesLegacy apps relying on session state; low concurrency
transactionAfter each COMMIT/ROLLBACKNot preserved across transactionsYes, within one transactionDefault choice for web apps and ORMs
statementAfter each statementNot preservedNo — one statement per transactionRead-only analytics, pgbouncer as a plain load balancer

transaction mode is the one that makes the "10,000 clients on 20 backends" math work, because a backend connection is only held for the duration of a single transaction, not a whole HTTP request's lifecycle or a whole client session. It's also the mode that introduces the sharp edges below.

The prepared statement trap

Prisma (like most modern ORMs) uses server-side prepared statements for query execution. A prepared statement is tied to the specific backend connection that created it. In session mode that's fine — the client keeps the same backend for its whole session. In transaction mode, PgBouncer can hand your next transaction to a different backend connection than the one that prepared the statement, and the query fails.

Two ways to handle this, and you need to pick one deliberately:

1. Disable prepared statements on the Prisma side. Add pgbouncer=true to the connection string. This tells Prisma's query engine to skip server-side prepared statements entirely and fall back to plain text queries.

DATABASE_URL="postgresql://app_user:password@pgbouncer-host:6432/app_production?pgbouncer=true&connection_limit=10&pool_timeout=30"

2. Let PgBouncer track prepared statements per client. Since PgBouncer 1.21, max_prepared_statements (default 0, i.e. off) enables protocol-level prepared statement support in transaction mode — PgBouncer re-prepares named statements on whatever backend it routes a client to and keeps an LRU cache per server connection. This lets you keep prepared statements without dropping to session mode, at the cost of some extra bookkeeping overhead in PgBouncer itself.

For most Prisma deployments behind PgBouncer, option 1 (pgbouncer=true) is simpler and is what Prisma's own documentation recommends as the default path — option 2 is worth it mainly if prepared-statement overhead is a measured bottleneck.

Sizing the pools — three numbers that all have to agree

The recurring production failure isn't "no pooling," it's three independently-configured pool sizes that don't line up:

  • Postgres `max_connections` — the hard ceiling on real backend connections (default 100).
  • PgBouncer `default_pool_size` — how many of those backend connections PgBouncer will open per database/user pair. This must leave headroom under max_connections for admin connections, other services, and PgBouncer's own overhead.
  • Prisma `connection_limit` — how many connections each Prisma Client instance opens to whatever it's pointed at (PgBouncer or Postgres directly). Prisma's documented default, when unset, is num_physical_cpus * 2 + 1 — so a 4-core app server defaults to 9 connections, per process.

That last default is the trap in horizontally-scaled or serverless deployments: if you run 20 container replicas with no explicit connection_limit, you get up to 180 connections before PgBouncer even enters the picture. In serverless (Lambda, Vercel functions), each concurrent invocation can spin up its own Prisma Client, so the standard advice is to set connection_limit=1 on the Prisma side — since PgBouncer (or a serverless-aware pooler) is doing the real multiplexing, a single-query-at-a-time function doesn't need its own mini-pool.

A workable baseline for a mid-size app server fleet:

Postgres max_connections        = 100
PgBouncer default_pool_size     = 20   (per db/user pair)
PgBouncer max_client_conn       = 1000 (cheap to raise; these are just TCP sockets)
Prisma connection_limit         = 5    (per app instance, times N instances ≤ 20)

When Prisma connection_limit × app instances exceeds PgBouncer's default_pool_size, requests start queuing inside PgBouncer instead of failing fast — which is usually the right failure mode, but only up to pool_timeout (Prisma's client-side wait) or query_wait_timeout (PgBouncer's own). Past that, you get Prisma's P2024: Timed out fetching a new connection from the connection pool — a recurring complaint in Prisma's own issue tracker, and it's a sizing problem, not a bug.

Alternatives worth knowing in 2026

PgBouncer isn't the only option, and for serverless or multi-region setups it's often not the best fit on its own:

  • Supabase Supavisor — a server-side pooler (written in Elixir) that Supabase runs in front of every project, positioned as a PgBouncer alternative that also handles named/prepared statements more transparently and scales pooling across a fleet rather than one process.
  • Neon's built-in pooler — connection pooling baked into Neon's serverless Postgres offering, aimed at the same "thousands of short-lived serverless connections" problem.
  • AWS RDS Proxy — a managed pooler for RDS/Aurora Postgres, useful if you want pooling without operating PgBouncer yourself.
  • Prisma Accelerate / Cloudflare Hyperdrive — application-side or edge-side poolers that sit closer to serverless/edge functions specifically, complementing rather than replacing a server-side pooler.

The pattern across all of them: an application-side pooler (what Prisma does internally) handles the "one process, many logical requests" problem; a server-side pooler (PgBouncer, Supavisor, RDS Proxy) handles the "many processes/functions, one database" problem. At real scale you usually need both, not one instead of the other.

If your team is evaluating whether to self-host PgBouncer, move to a managed pooler, or restructure how database connections are provisioned across environments, that's the kind of infrastructure decision that's easy to get wrong quietly — it's the sort of thing our cloud & DevOps engineering work deals with directly when we're brought in to stabilize a database layer under load.

FAQ

Do I still need PgBouncer if I'm using Prisma Accelerate?

Usually not for the connection-count problem specifically — Accelerate is designed to solve serverless/edge connection scaling on its own via a managed connection pool. Some teams still run a server-side pooler behind it for non-Prisma workloads hitting the same database.

What does `pgbouncer=true` actually change in Prisma's behavior?

It disables server-side prepared statements in Prisma's query engine, so queries are sent as plain text instead of PREPARE/EXECUTE. This avoids the "prepared statement on a connection I no longer have" failure that happens under PgBouncer's transaction pooling mode.

Can I run PgBouncer in transaction mode with session-level features like `SET search_path`?

Not safely — anything that sets connection-level state (session variables, temp tables, advisory locks held across statements) can leak onto the wrong client's next query, or simply not persist, because the backend connection changes between transactions. Put that logic in the query itself or use session mode for that workload.

How do I know if `P2024` is a sizing problem versus a slow-query problem?

Check whether the timeouts cluster during traffic spikes with otherwise-normal query latency (a sizing problem — raise connection_limit/default_pool_size headroom or reduce instance count) versus timeouts correlating with individual slow queries holding connections open (a query performance problem — pooling can't fix a query that's the bottleneck).

Sources