Choosing a Connection Proxy for Serverless Postgres
This guide is part of PgBouncer vs RDS Proxy vs pgpool-II. It addresses the specific failure that drives most serverless teams to a proxy in the first place: a Lambda or Cloud Run fleet that scales horizontally faster than PostgreSQL can accept connections, producing a connection storm. The database hits its ceiling and starts rejecting logins with:
FATAL: sorry, too many clients already
FATAL: remaining connection slots are reserved for non-replication superuser connections
The mechanism is structural. PostgreSQL allocates a backend process (and roughly 5–10 MB of memory) per connection, so max_connections is typically capped in the low hundreds. A serverless platform, by contrast, spins up one isolated execution environment per concurrent request. At 500 concurrent invocations, if each function opens even one connection, you need 500 backend slots — well past a default max_connections = 100. Functions also freeze between invocations and reconnect on thaw, so connection churn is high and connection lifetime is short. A traditional long-lived application pool assumes the opposite of both facts. This guide walks through which proxy to put in the middle and how to size the in-function pool so the storm never reaches the database.
Rapid incident diagnosis
When the storm is live, check three things in order.
First, count backends from the database side:
SELECT count(*), state FROM pg_stat_activity GROUP BY state;
If count is at or near max_connections and many rows are idle, the functions are opening connections they barely use — the classic serverless pattern. Those idle connections are pure waste from horizontal scaling.
Second, distinguish exhaustion from starvation. Exhaustion is the database refusing logins (too many clients already). Starvation is the in-function client waiting on its own pool while the database still has slots free — usually because the function set a client pool size greater than 1 and the connections are stranded across frozen environments. The fix differs: exhaustion needs a proxy, starvation needs a smaller in-function pool.
Third, if a proxy is already in place, check for pinning. On RDS Proxy, a high DatabaseConnectionsCurrentlySessionPinned means multiplexing has collapsed and each function effectively holds a dedicated backend, reproducing the storm one layer down.
Decision walkthrough
There are three realistic architectures. The choice turns on platform, prepared-statement needs, and how much you want to operate.
| Option | Best when | Multiplexing | Auth | Operational cost |
|---|---|---|---|---|
| RDS Proxy | On AWS RDS/Aurora | Transaction-style, managed | Native IAM | Lowest (managed) |
| PgBouncer on instance | Any cloud, need max ratio | Excellent (transaction) |
DIY (SCRAM/TLS) | Medium |
| Serverless driver (HTTP) | Edge/short queries, no long sessions | Provider pools server-side | Token/HTTP | Low, but app rewrite |
RDS Proxy is the default for AWS-hosted Postgres. It absorbs the connection storm with transaction-level multiplexing, authenticates functions with short-lived IAM tokens (no static secrets in the function), and survives failover by holding the client socket open. Its weakness is session pinning: any session-level SET, advisory lock, or server-side prepared statement pins a function to a backend and erodes the ratio. Keep functions stateless to preserve it. The full behavior is in AWS RDS Proxy Connection Pooling.
PgBouncer on an instance gives the highest multiplexing ratio and the lowest added latency, and runs anywhere. The cost is that you now operate a process — patching, monitoring, and making it itself highly available so it is not a single point of failure in front of your database. Choose it when you need to collapse thousands of clients onto a couple dozen backends and RDS Proxy’s pinning or ratio is not good enough. Run it in pool_mode = transaction; see PgBouncer Transaction vs Statement Pooling for why session mode would defeat the purpose here.
A serverless HTTP driver sidesteps the wire protocol entirely: the function makes a stateless HTTP call to a connection-pooling gateway that holds the real connections. This is the cleanest fit for edge runtimes and short queries, but it requires using the provider’s driver and rules out long-lived transactions and session features.
The transaction-pooling requirement
Whatever proxy you choose, serverless demands transaction-level pooling, not session-level. Session pooling holds a backend connection for the client’s entire session, which for a function means the connection is parked while the environment is frozen — exactly the waste the proxy was meant to eliminate. Transaction pooling returns the backend at COMMIT, so a frozen function holds nothing. This is non-negotiable for serverless and is the single most important proxy setting.
The corollary is the prepared-statement constraint. Under transaction pooling, server-side prepared statements break because consecutive statements can land on different backends. In functions, disable them: prepareThreshold=0 on JDBC, or the simple-query protocol on node-postgres. With RDS Proxy, leaving them on is also a documented pinning trigger.
Why client pools of size 1 in functions
The instinct to give each function a pool of 10 connections is wrong, and it is the second most common cause of serverless connection storms.
A serverless environment serves one request at a time. There is no in-process concurrency for a pool to amortize across, so a pool larger than 1 just opens connections that sit idle until the environment is reclaimed. Multiply that by hundreds of concurrent environments and you have manufactured the storm yourself, on top of the database. The correct in-function configuration is a pool with a maximum size of 1 (or a single bare connection), pointed at the proxy, which then does the real multiplexing across all functions.
// node-postgres inside a Lambda/Cloud Run function
import { Pool } from "pg";
// One connection per execution environment, reused across warm invocations.
const pool = new Pool({
host: process.env.PROXY_HOST, // PgBouncer or RDS Proxy endpoint
max: 1, // never more than 1 in a function
idleTimeoutMillis: 0, // keep it for warm reuse
allowExitOnIdle: false,
});
export const handler = async (event) => {
const client = await pool.connect();
try {
await client.query("BEGIN");
const res = await client.query("SELECT id FROM jobs WHERE ready = true LIMIT 10");
await client.query("COMMIT");
return res.rows;
} finally {
client.release();
}
};
Declaring the pool in module scope — outside the handler — lets a warm environment reuse the same connection across invocations instead of reconnecting on every call, which cuts connection churn dramatically. max: 1 ensures one environment never holds more than one backend slot through the proxy. Connection sizing for the node-postgres pool specifically is covered in Sizing the node-postgres Pool for Serverless.
Sizing the backend pool
Size the proxy’s backend pool from peak concurrent transactions, not from the function concurrency limit, using a Little’s Law framing. If your functions execute R transactions per second and each transaction holds a backend for T seconds, the steady-state backend demand is R × T.
Worked example: 800 invocations/second, each running one transaction that holds a connection for 8 ms. Demand = 800 × 0.008 = 6.4 backends in steady state. Provision the proxy backend pool to roughly 2–3× that for headroom against bursts — about 16–20 backends — and confirm it stays comfortably under the database max_connections. The function fleet may peak at 800 concurrent environments, but the proxy never opens more than ~20 backends, which is the entire point.
Validation & verification
After deploying, confirm three things. First, that the storm is gone — from the database:
SELECT count(*) AS backend_count,
(SELECT setting::int FROM pg_settings WHERE name='max_connections') AS max_conn
FROM pg_stat_activity;
backend_count should plateau near the proxy’s backend pool size, far below max_conn, even under peak function load.
Second, on RDS Proxy, verify multiplexing held: DatabaseConnectionsCurrentlySessionPinned in CloudWatch should stay near zero. On PgBouncer, SHOW POOLS should show cl_waiting = 0 with sv_active bounded by default_pool_size.
Third, load-test the function fleet at peak concurrency and assert no too many clients already errors appear in either the function logs or pg_log during the run.
Frequently Asked Questions
Do I still need an in-function pool if I have a proxy?
RDS Proxy or PgBouncer for a Lambda fleet on Aurora?
Why do prepared statements break behind the proxy?
prepared statement "S_1" does not exist. Disable server-side prepared statements in the driver, or accept session pinning.Can I just raise max_connections instead of adding a proxy?
max_connections into the thousands exhausts database memory and degrades scheduling long before it solves a serverless storm. A proxy is the structural fix; raising the limit is a stopgap.Are serverless HTTP drivers a full replacement for a proxy?
Related
- PgBouncer vs RDS Proxy vs pgpool-II — the head-to-head comparison this decision builds on.
- AWS RDS Proxy Connection Pooling — managed-proxy mechanics including session pinning.
- PgBouncer Transaction vs Statement Pooling — why transaction mode is mandatory for functions.
- Sizing the node-postgres Pool for Serverless — pool-size-1 configuration for node-postgres.