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?
Use a pool of maximum size 1 declared at module scope. It exists only to enable warm reuse of a single connection across invocations and to give you a clean borrow/release API; it must never open more than one connection, since the proxy does the real multiplexing.
RDS Proxy or PgBouncer for a Lambda fleet on Aurora?
Start with RDS Proxy. It is managed, gives IAM authentication, and handles failover by holding the client socket. Move to self-hosted PgBouncer only if session pinning is eroding your multiplexing ratio or you need a higher client-to-backend ratio than RDS Proxy delivers.
Why do prepared statements break behind the proxy?
Transaction pooling can route consecutive statements from one client to different backend connections, so a server-side prepared statement created on one backend does not exist on the next, raising 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?
Only marginally. Each connection costs a backend process and several megabytes of memory, so raising 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?
For short, stateless queries on edge runtimes, yes — they pool connections server-side and remove the wire-protocol problem. They are not a fit when you need long-lived transactions, session state, or features the HTTP driver does not expose, in which case a transaction-pooling proxy in front of standard drivers is the right call.