The wall every serverless application hits
Postgres has a connection limit, and serverless applications do not respect it. Every function invocation that opens a direct database connection holds a Postgres backend for the life of that invocation, and a traffic spike that spins up 800 concurrent invocations asks Postgres for 800 backends at once. A database configured with a max_connections limit of 100 answers the first 100 and refuses the rest, and the application fails precisely at the moment it is succeeding commercially. The pooler exists because the alternative, raising max_connections until the database falls over under backend overhead, is not a plan.
A connection pooler sits between the application and Postgres and multiplexes many application connections through a small set of real server connections. Railway states the mechanism plainly in its PgBouncer documentation: PgBouncer multiplexes many application connections into a smaller pool of server connections, reducing overhead and allowing the database to serve far more concurrent clients than max_connections alone would permit. Supabase states the same mechanism for Supavisor: a pooler sits in front of Postgres and shares a small set of database connections across many clients, which avoids exhausting Postgres connection limits. The choice is not whether to pool. The choice is which pooler, in which mode, at which size, and that choice is decided by one question: does your application need session state to survive between transactions?
> [!KEY TAKEAWAY]
> Pooling is not a performance optimisation. It is a capacity decision with a hard arithmetic: total server connections equal pools multiplied by pool size, and that total must fit inside max_connections with room for an administrator to log in during an incident. Every other consideration is secondary to that equation.
Who this playbook is for, and who should stop here
This playbook is for teams running Postgres behind serverless functions, edge workers, or autoscaled API fleets, and for teams choosing between a managed Supabase deployment and a self-operated PgBouncer. If your application holds a handful of long-lived connections from a single server, you do not have this problem yet, and adding a pooler adds a moving part without a payoff.
Stop here and use a direct connection instead if any of these apply: you run schema migrations, pg_dump, or long-lived backends, which Supabase itself reserves for the direct connection; your application depends on LISTEN and NOTIFY or advisory locks as core behaviour rather than as an edge case; or your workload is a single monolith with a bounded connection count that already fits comfortably inside max_connections. A pooler that nothing needs is latency and failure surface for no gain.
The three modes, decided by one question
PgBouncer and Supavisor both expose session mode and transaction mode, and PgBouncer adds statement mode. The mode decides when a server connection returns to the pool, and that single timing decision decides everything else.
In session mode, each client holds a server connection for its entire session. Every Postgres feature works, because the client effectively owns a backend, but pooling only helps when clients disconnect. Railway states the limitation directly: pooling only helps when clients disconnect. In transaction mode, server connections are reused per transaction, which gives the best multiplexing, and session-level features are not supported. In statement mode, server connections are reused per statement, and multi-statement transactions are not allowed at all.
| Mode | Server connection released | What breaks | Vendor authority |
| :--- | :--- | :--- | :--- |
| Session | When the client disconnects | Nothing; every feature works | Railway PgBouncer docs; Supabase session mode on port 5432 |
| Transaction | When the transaction commits or rolls back | SET held across statements, LISTEN and NOTIFY, advisory locks, temporary tables spanning transactions, prepared statements on Supavisor | Railway PgBouncer docs; Supabase transaction mode on port 6543 |
| Statement | When each statement completes | Everything above, plus multi-statement transactions | Railway PgBouncer docs |
The port tells you the mode on Supabase. Session mode runs on port 5432 and transaction mode on port 6543, and the choice is explicit in the connection string rather than buried in configuration. On Railway the mode is selected when PgBouncer is added, with transaction as the default, and it can be changed later from the PgBouncer overview panel, staged to take effect on the next PgBouncer deployment without restarting the database. Either way, the application picks its mode every time it dials, so a connection string copied from a migration guide into application code silently picks the wrong mode more often than any configuration error does.
The arithmetic that sizes the pool
Pool size is per pool, and a pool is one user-plus-database pair. That single fact is the most common sizing error in production, because operators size one pool and forget they run several. The total is:
$
\text{Total Server Connections} = \text{Pools} \times \text{Default Pool Size}
$
Work it with Railway’s defaults. Four application users against two databases create 8 pools. At the default pool size of 20 server connections per replica, the pooler can open 160 server connections. Against a Postgres max_connections limit of 100, that configuration fails at full utilisation: the pooler’s ceiling exceeds the database’s ceiling by 60 connections, and the excess appears as refused connections under load, which is the worst time to discover arithmetic. The correction is to cut the pool size, cut the number of pools, or raise max_connections with headroom reserved for direct admin access. Supabase’s own documentation states the same rule for its defaults: keep the default pool size below max_connections minus connections reserved for other services.
Replica multiplication is the second trap. Railway’s PgBouncer overview scales replicas from 1 to 6, and each replica multiplies both client capacity and server connections. At the defaults of 20 server connections and 1,000 client connections per replica, one replica serves up to 1,000 clients through 20 server connections, and three replicas serve up to 3,000 clients through 60 server connections. The marketing number grows, and so does the server-side bill. Check the product of replicas and pool size against max_connections after every scaling change, not just at initial setup.
Throughput follows the same discipline. A pool of 20 server connections serving transactions that average 50 milliseconds each has a ceiling of:
$
\text{Throughput Ceiling} = \frac{\text{Server Connections}}{\text{Average Transaction Seconds}} = \frac{20}{0.05} = 400 \text{ transactions per second}
$
Demand above that ceiling does not fail fast. It queues. An application driving 800 concurrent single-transaction requests, each taking 40 milliseconds, through a 20-connection pool can complete 500 transactions per second at full utilisation, so roughly 300 requests wait at any instant and tail latency grows with queue depth. The pooler did not create that queue; it exposed the real capacity of the database, which is the point. When the queue grows, the answers are faster transactions, a larger pool within the max_connections budget, or read scaling, in that order. Raising client limits without raising server capacity only lengthens the queue.
Supavisor versus PgBouncer versus direct
Supavisor is Supabase’s default pooler, written for multi-tenant operation: a single cluster pools for many databases, routing by a tenant ID carried in the username, so a connection string user looks like postgres with a tenant suffix rather than a plain role. Its self-hosted defaults are a default pool size of 20 Postgres connections per pool, a maximum of 100 client connections, and an internal metadata pool of 5. Its transaction-mode connection string uses port 6543 and requires the tenant ID for authentication. Its documented limits in transaction mode are the sharp ones: no session-level features, and no prepared statements.
PgBouncer, whether self-operated or through Railway, is the older, narrower tool: a single-tenant pooler with three modes, a default pool size of 20 and a default maximum of 1,000 client connections per replica on Railway, and a PgBouncer override in self-hosted Supabase that runs transaction mode only and reuses the Supavisor pool-size variables. Its prepared-statement story is the differentiator Supabase documents explicitly: PgBouncer can be configured to support prepared statements, while the Supavisor pooler does not support them. For ORM-heavy applications that prepare statements by default, that sentence alone can decide the deployment.
| Option | Best for | Server connections at defaults | Session features | Prepared statements in transaction mode | Vendor authority |
| :--- | :--- | :--- | :--- | :--- | :--- |
| Supavisor transaction mode, port 6543 | Serverless and edge functions, managed Supabase | 20 per pool, 100 clients | Not supported | Not supported | Supabase self-hosting docs |
| Supavisor session mode, port 5432 | Persistent clients needing session state | One per client for the session life | Fully supported | Supported, as a dedicated connection | Supabase self-hosting docs |
| PgBouncer transaction mode, Railway | Self-operated or Railway Postgres with serverless clients | 20 per replica, 1,000 clients per replica | Not supported | Configurable | Railway PgBouncer docs |
| Direct connection, port 5432 | Migrations, pg_dump, long-lived backends | One per client, bounded only by max_connections | Fully supported | Fully supported | Supabase direct connection guidance |
The migration playbook
Move an application to a pooler in this order. Reversing the order is how teams discover session features in production.
1. Inventory session features. Search the codebase for SET statements outside transactions, LISTEN and NOTIFY, advisory locks, temporary tables that outlive a transaction, and prepared-statement usage. Every hit is a route to the unpooled endpoint, not a reason to stay off the pooler.
2. Route operations to the unpooled endpoint first. Railway provides a dedicated unpooled connection string for exactly this purpose, and Supabase provides the direct connection. Migrations, concurrent index builds, and administrative scripts move before application traffic does.
3. Point application traffic at the transaction-mode endpoint. On Supabase that is port 6543 with the tenant-qualified username. On Railway it is the pooled connection string issued when PgBouncer is added. Change one environment at a time, staging before production.
4. Load test at 2x expected peak. Watch queue depth and tail latency, not averages. A pool that passes at average load and queues at peak has not been tested.
5. Set alerts on pool saturation. Alert when waiting clients appear or when server connections approach max_connections, not when the application is already refusing connections.
6. Document the rollback. Rollback is a connection-string revert to the direct endpoint. Test that the direct endpoint still accepts connections before you need it in an incident.
> [!WARNING]
> Statement mode is the trap door in this whole design. It looks like the most aggressive optimisation, because a server connection returns after every statement, but it forbids multi-statement transactions entirely. An application that opens a transaction, runs two statements, and commits will fail on the second statement under statement mode, and the failure reads as an application bug rather than a configuration choice. Never select statement mode for a general application. Reserve it for read-only analytical queries that genuinely consist of single statements.
> [!FOUNDER]
> "Size the pool on paper before you size it in production, because the database will enforce your arithmetic at the worst possible moment. Twenty connections per pool sounds small until you multiply it by your users, your databases, and your replicas."
> — Enow A. Jovial, Founder & Chief Executive Officer
Failure modes that repeat
Pool exhaustion is the first and most misdiagnosed failure. The application reports connection timeouts, the database looks healthy, and the pooler queue is the actual bottleneck. The fix is rarely a larger client limit, which only lengthens the queue; it is faster transactions, a larger server pool inside the max_connections budget, or fewer pools through fewer database users. Watch SQL latency separately from pool latency: a slow SQL query holds a server connection for its full duration, so one unindexed SQL query can saturate a 20-connection pool faster than any client count can.
Replica multiplication is the second. A team scales PgBouncer replicas for client capacity and forgets that server connections multiply with them, until the database refuses connections that no single replica would have caused. Check the product after every scaling event.
The third failure is the prepared-statement surprise after a mode switch. An ORM that prepared statements happily against a direct connection starts failing against a transaction-mode pooler, and the errors look like driver bugs. On Supavisor the answer is to disable prepared statements for the pooled connection. On PgBouncer the answer is configuration. Either way the answer is decided before the switch, during the feature inventory, not during the incident.
> [!TIP]
> Keep one pooler configuration per environment and never share a pool between staging and production. A staging load test that saturates a shared pool takes production down with it, and the incident review will find the sharing long before it finds the test. Separate pools also make the arithmetic honest: the pool size you verify in staging is the pool size production actually gets.
When to get help instead of a bigger pool
Bring in database expertise when the pool arithmetic says the workload cannot fit: when pools multiplied by a workable pool size still exceed max_connections, when average transaction time is dominated by lock waits rather than query time, or when the application genuinely needs session features at serverless scale. Those are architecture problems, and a bigger pool only postpones them until the next traffic spike. The pooler is the right tool for multiplexing short transactions. It is not a substitute for indexing, for query design, or for read replicas, and treating it as one is how teams end up with a large pool in front of a slow database.
Related reading on this site: the Cloudflare Workers plus D1 versus Hyperdrive edge SQL benchmark for the edge-latency side of the same decision, and the Cloudflare Workers versus Vercel edge functions comparison for where pooled Postgres fits in an edge architecture. To model the instance-size saving from a smaller server pool before committing, run the numbers through the break-even calculator.