Sizing the node-postgres Pool for Serverless

This guide is part of Node.js Async Connection Limits. Serverless runtimes break the assumptions that pg.Pool was designed around: each Lambda or Cloud Function execution environment runs a single concurrent invocation, but the platform spins up hundreds or thousands of those environments under load, and every one keeps its own pool alive across warm invocations. A pool sized for a long-lived server — max: 10 or max: 20 — multiplied across a fan-out of concurrent containers produces a connection storm that exhausts Postgres max_connections in seconds.

The failure manifests both at the database and in the function logs. Postgres rejects new backends outright:

error: remaining connection slots are reserved for non-replication superuser connections
    at Parser.parseErrorMessage (/var/task/node_modules/pg-protocol/dist/parser.js:287:98)
  code: '53300'

and the function itself either times out waiting for a slot or, worse, the execution environment is frozen by the platform mid-request, leaving a connection that the database still considers open:

Task timed out after 30.00 seconds
# next invocation in the same container:
Error: timeout exceeded when trying to connect   // connectionTimeoutMillis hit; pool slot leaked by frozen container

The objective of this guide is to size pg.Pool for the one-invocation-per-container reality (almost always max: 1), tame the connection lifecycle so frozen containers do not leak slots, and decide when the pool must move behind RDS Proxy or PgBouncer so the connection count is decoupled from container count entirely.

Rapid incident diagnosis

Serverless connection problems look like ordinary exhaustion but have a different root cause — container fan-out, not in-process concurrency. Triage in this order.

  1. Count distinct client backends at peak. Query pg_stat_activity and compare the active backend count to max_connections. If the count tracks your concurrent-invocation count rather than your configured max, the storm is container fan-out: each container holds its own pool.
  2. Check the per-container max. A serverless container serves one request at a time, so any max > 1 is wasted slots that the platform will never use concurrently within that container but that still count against the database.
  3. Look for frozen-container leaks. After a Task timed out or a deploy, look for backends in idle state with old backend_start and no corresponding live invocation. Frozen environments cannot run pool.end(), so their connections linger until idle_in_transaction_session_timeout or TCP keepalive reaps them.
  4. Inspect connectionTimeout failures. timeout exceeded when trying to connect from pg means the pool could not get a slot — at max: 1 this means the single connection is busy or wedged, often by a prior request that did not release. Cross-reference Fixing Async Connection Pool Exhaustion in Node.js for client-release bugs.
  5. Decide pool vs. proxy. If concurrent invocations exceed what the database can hold even at max: 1 per container, no client-side sizing fixes it — you need a multiplexing proxy. See Choosing a Connection Proxy for Serverless Postgres.

Mathematical sizing / parameter formula

In serverless, the database connection count is not set by your pool max — it is set by the number of concurrent execution environments. The pool max only multiplies that count.

Define:

  • C = peak concurrent invocations (e.g., Lambda reserved/peak concurrency)
  • m = pg.Pool max per container
  • M = database max_connections
  • R = reserve for admin/replication/migrations (15–20% of M)

Total direct connections at peak:

total_connections = C × m
constraint:        C × m  ≤  M − R

Because a container serves one invocation at a time, m = 1 is correct; raising it cannot increase that container’s concurrency, it only raises C × m. So the real constraint becomes:

C ≤ M − R      (direct connections, m = 1)

When peak concurrency C exceeds M − R, direct connections are impossible and a proxy must absorb the fan-out: the proxy holds a small server-side pool P_server and multiplexes many client connections onto it.

With a proxy:  P_server ≈ ceil(λ × S),   independent of C

where λ is aggregate query throughput and S is mean query service time.

Worked example. Postgres max_connections = 200, reserve R = 40 (20%), so the direct budget is M − R = 160. The function peaks at C = 500 concurrent invocations.

Direct path:  C × m = 500 × 1 = 500  >  160   →  impossible, even at max:1

Direct connections fail by 3×. Move behind a proxy. Aggregate throughput λ = 900 qps, mean service time S = 0.020 s:

P_server = ceil(λ × S) = ceil(900 × 0.020) = ceil(18) = 18 server connections

The proxy holds ~18 connections to Postgres while accepting all 500 client connections, multiplexing them at the transaction level. The database now sees 18 backends instead of 500, well inside the 160 budget. If peak concurrency had instead been C = 120, direct max: 1 pools would fit (120 ≤ 160) and a proxy would be optional — but max: 1 is still mandatory to keep the multiplier at one.

Peak concurrency C vs. budget M − R Path Pool config
C ≤ M − R direct connections pg.Pool max: 1 per container
C > M − R multiplex behind proxy max: 1 to proxy; proxy P_server = ceil(λ×S)
highly spiky, idle troughs proxy + idle reaping allowExitOnIdle: true, short idleTimeoutMillis

Exact remediation & configuration

Set max: 1, fail fast on connect, and reap idle connections so a warm container that goes quiet does not hold a slot indefinitely.

Direct pg.Pool for serverless (concurrency within the connection budget)

// db.js — module scope, reused across warm invocations in the same container
const { Pool } = require('pg');

const pool = new Pool({
  max: 1,                       // one invocation per container; >1 only wastes DB slots
  min: 0,                       // do not pre-open; let cold starts establish lazily
  idleTimeoutMillis: 1000,      // release the idle connection quickly between bursts
  connectionTimeoutMillis: 2000,// fail fast instead of hanging the whole invocation
  allowExitOnIdle: true,        // let the event loop drain so the container can freeze cleanly
});

module.exports = { pool };

allowExitOnIdle: true is important in serverless: it lets the Node event loop empty when the pool is idle, so the platform can freeze the container without a lingering open socket. idleTimeoutMillis: 1000 ensures the single connection is returned to the OS soon after a burst, reducing the window in which a frozen container holds a slot.

Handler — never call pool.end() per invocation

const { pool } = require('./db');

exports.handler = async (event) => {
  const client = await pool.connect();
  try {
    const { rows } = await client.query('SELECT id FROM jobs WHERE status = $1 LIMIT 50', ['ready']);
    return { statusCode: 200, body: JSON.stringify(rows) };
  } finally {
    client.release();           // ALWAYS release; a missed release wedges the max:1 pool
  }
  // Do NOT call pool.end() here — that destroys the pool for the next warm invocation.
};

Calling pool.end() per invocation forces a fresh TCP+TLS handshake on every warm start, defeating reuse and spiking connection rate. Release the client, keep the pool.

Behind RDS Proxy / PgBouncer (when C exceeds the budget)

const pool = new Pool({
  host: 'my-db-proxy.proxy-abc123.us-east-1.rds.amazonaws.com', // RDS Proxy endpoint
  max: 1,
  idleTimeoutMillis: 1000,
  connectionTimeoutMillis: 2000,
  allowExitOnIdle: true,
});
; pgbouncer.ini — transaction pooling multiplexes thousands of clients onto few server conns
[pgbouncer]
pool_mode = transaction
max_client_conn = 5000      ; accept the full serverless fan-out
default_pool_size = 18      ; server-side connections to Postgres = ceil(lambda * S)
server_idle_timeout = 60

Transaction-mode pooling is what decouples connection count from container count. Note that transaction pooling disables session-scoped features (server-side prepared statements, SET, advisory locks held across statements); confirm your queries are compatible before switching.

Apply changes by deploying a new function version; serverless containers are immutable, so the new pool config takes effect as fresh containers replace old ones. There is no in-place pool resize.

Validation & verification

Confirm the database connection count tracks the proxy server pool (or C), not a multiplied figure, and that frozen containers no longer leak.

Database-side: connection count under peak load.

SELECT count(*) AS backends,
       state,
       client_addr
FROM pg_stat_activity
WHERE datname = current_database()
  AND backend_type = 'client backend'
GROUP BY state, client_addr
ORDER BY backends DESC;

Direct path: total backends should approximate peak concurrent invocations, never C × max. Proxy path: total backends should stay near default_pool_size regardless of how many functions are running — that is the multiplexing working.

Leak check after timeouts. Look for idle backends whose backend_start predates current traffic and that never advance to active:

SELECT pid, state, backend_start, state_change
FROM pg_stat_activity
WHERE state = 'idle'
  AND state_change < now() - interval '2 minutes';

With idleTimeoutMillis: 1000 and allowExitOnIdle: true, these should be rare and short-lived. A growing population means containers are freezing with the pool still holding a connection — shorten idleTimeoutMillis or move to a proxy that reaps client connections server-side.

Load-test assertion. Drive the function at peak concurrency and assert the database backend ceiling holds:

# Fire C concurrent invocations and confirm pg backends never exceed the budget
aws lambda invoke --function-name my-fn --invocation-type Event /dev/null  # x C, via a load tool
# then, against the DB:
psql -c "SELECT count(*) FROM pg_stat_activity WHERE backend_type='client backend';"

The reported count must stay below max_connections − reserve throughout. If it spikes past the budget, either max is greater than 1 somewhere, or fan-out exceeds the direct budget and the proxy path is required.

Frequently Asked Questions

Why should pg.Pool max be 1 in Lambda?
A serverless execution environment processes exactly one invocation at a time, so a single connection is all that container can ever use concurrently. Any max above 1 reserves additional database slots that sit unused per container but still count against max_connections across the whole fan-out. Setting max: 1 keeps the connection multiplier at one and the total at the number of concurrent containers.
Should I call pool.end() at the end of each handler?
No. Calling pool.end() destroys the pool, so the next warm invocation in the same container must rebuild it with a full TCP and TLS handshake, spiking latency and connection rate. Release the client with client.release() and leave the pool intact at module scope so warm invocations reuse it. Only call pool.end() during an intentional shutdown signal, which serverless rarely provides.
What causes connections to leak when a container is frozen?
The platform can freeze an execution environment between invocations or after a timeout without giving your code a chance to run cleanup. A connection the pool still holds remains open on the database until idle_in_transaction_session_timeout, TCP keepalive, or the proxy reaps it. Minimize the window with a short idleTimeoutMillis and allowExitOnIdle: true, and prefer a proxy that closes idle client connections server-side.
When do I need RDS Proxy or PgBouncer instead of just sizing the pool?
When peak concurrent invocations exceed your database connection budget (max_connections minus reserve), no client-side max can fit, because each container still needs at least one connection. A transaction-mode proxy multiplexes thousands of client connections onto a small server-side pool sized by throughput, decoupling database connections from container count entirely.
Does transaction pooling break anything in node-postgres?
Transaction-mode pooling rotates the underlying server connection between transactions, so session-scoped state does not survive: server-side prepared statements, SET/SET LOCAL outside a transaction, LISTEN/NOTIFY, and advisory locks held across statements all break. Use simple or parameterized queries within a single transaction, and disable client-side statement caching that assumes a stable backend before switching to transaction mode.