Why PgBouncer in Transaction Mode Is the Default

#postgres #infrastructure #databases

A bare Postgres instance handles a few hundred connections before context switching, per-connection memory, and the cost of fork() per backend eat into latency. Our backend, four workers, a chatbot consumer, and an embedding consumer all want their own pools. Add a serverless cron and the math gets worse.

PgBouncer in transaction mode multiplexes thousands of client connections onto a small fixed pool of server connections. The pool is held across requests, never per-transaction — that’s the whole point.

What you give up

Transaction-mode pooling forbids session-level state that survives a transaction boundary:

For an ORM-heavy backend this is mostly invisible. Drizzle and Payload both behave correctly here. The one place we got bit was a long-running analytics query that opened a cursor and streamed rows — we moved it to a direct connection bypassing the bouncer.

What it bought us

The rule

Default everything to the pooled connection. Bypass the bouncer only for the rare workload that needs session state — and write that workload’s config down explicitly, because the next engineer to touch it will assume the pool is universal.