The question this answers
Why does a function that worked perfectly in staging exhaust the database the first time real traffic arrives?
The order API reads and writes PostgreSQL on every request. It has to keep doing that after being moved from three long-lived application servers onto a function platform.
A connection topology that survives per-invocation scaling: a small, stable number of database connections held by something that persists, with the function borrowing rather than owning.
The arithmetic
A traditional application server is a long-lived process with a connection pool. Twenty servers × a pool of ten is two hundred connections, opened at boot, held for weeks, and reused across millions of requests. The database sizes its max_connections for that world, and every connection costs it real memory and a backend process.
A function environment is neither long-lived nor shared. Each environment is its own process with its own pool, and concurrency is the number of environments. At 1000 concurrent invocations you have 1000 pools. Even a pool of one is 1000 connections against a database configured to accept a couple of hundred — and a pool of ten is 10,000. The application code did not change. The multiplier did.
The failure is abrupt and asymmetric. Connection attempts start being refused, so *new* invocations fail while already-connected ones keep working, which makes the error rate look random. Worse, every rejected invocation retries, and the retry opens another connection attempt, so the pressure rises exactly when you need it to fall. This is the same exhaustion the Database Engineering domain teaches, arriving through a new door.
BEFORE — long-lived application servers instances 3 pool size per instance 10 connections at rest 30 <= stable, opened once at boot connections at peak 30 <= identical; the pool is the ceiling AFTER — function platform, no proxy concurrent executions 1000 <= a consequence of arrival rate pool size per environment 1 <= even the minimum connections attempted 1000 database max_connections 100 <= unchanged ------------------------------------ outcome 900 refused, each one retried AFTER — function platform, pooled through a proxy concurrent executions 1000 proxy client connections 1000 <= cheap: multiplexed, not backend processes proxy -> database 40 <= stable, reused across executions outcome served, with queueing under the proxy instead of errors
The answer: something persistent has to hold the pool
The structural fix is to move the pool out of the thing that scales per request and into something that does not. A connection proxy — a managed database proxy, or a pooler such as PgBouncer running as a small always-on service — accepts a large number of cheap client connections and multiplexes them onto a small number of real backend connections. The function opens a connection to the proxy on every invocation, which is fine, because a proxy connection is not a database backend process.
Two details decide whether this actually works. First, pooling mode: transaction-level pooling gives the best multiplexing ratio but breaks anything that assumes a session — prepared statements held across calls, session-level SET statements, advisory locks, and LISTEN/NOTIFY. Session-level pooling preserves all of that and multiplexes far less. Picking the aggressive mode without auditing the application is the second-most-common way this goes wrong.
Second, the proxy is now on the path of every query, so it is a component with its own availability, its own connection limits and its own latency. It usually adds a small number of milliseconds — worth it — but it is a new single point of failure unless it is deployed across zones, and it is a new thing to monitor. "Add a proxy" is a recommendation with a cost, like every other one in this domain.
The other three answers, in order of preference
A proxy is the general fix, not the only one, and it is not always the first thing to try. Reserved concurrency on the function caps how many environments can exist at once, which caps connections directly — you are choosing to throttle at the function rather than to fail at the database, which is almost always the better place to fail. It costs you throughput you might have wanted.
Reaching the data over an HTTP data API removes connections from the picture entirely, since HTTP is stateless and the provider does the pooling on its side. It costs you the SQL client ecosystem, some latency, and often transactional ergonomics. And for a great many workloads the honest answer is that the function should not be talking to a relational database on the request path at all: writing to a queue and letting a small persistent worker do the database work keeps the pool where pools belong.
What does *not* work is raising max_connections. Each connection costs the database memory and a process; multiplying them trades a connection error for a memory-pressure incident, which is strictly worse because it is slower to diagnose and takes the primary down instead of one request. See Managed Databases.
// module scope: runs once per execution environment
const pool = new Pool({ max: 10, connectionString: process.env.DATABASE_URL })
export const handler = async (event) => {
const client = await pool.connect()
try {
return await client.query('select * from orders where id = $1', [event.id])
} finally {
client.release() // released to *this environment's* pool, not to anyone else's
}
}
// 1000 concurrent environments x max 10 = up to 10,000 connections requested
// against a database that accepts a few hundred.// module scope: one connection, reused while this environment stays warm
const db = new Client({
connectionString: process.env.PROXY_URL, // proxy, not the database
// IAM/short-lived auth rather than a static password where the provider supports it
})
let connected = false
export const handler = async (event) => {
if (!connected) { await db.connect(); connected = true } // lazy: off the cold path
return await db.query('select * from orders where id = $1', [event.id])
}
// 1000 environments x 1 = 1000 cheap proxy connections
// multiplexed onto ~40 real backend connections.The pool has to live in something whose count does not track request concurrency. In a function, the only correct pool size is one — and even that is one *per environment*, so the multiplexing still has to happen somewhere downstream.
Key points
- Concurrency multiplies pools: N concurrent environments means N pools, and even a pool of one at N=1000 overwhelms a database sized for a few hundred connections.
- Exhaustion presents as a random-looking error rate, because already-connected invocations keep succeeding while new ones are refused.
- Retries make it worse: every rejected invocation opens another connection attempt at the exact moment pressure needs to fall.
- The fix is to move the pool into something persistent — a proxy or pooler multiplexing many cheap client connections onto few backend ones.
- Raising max_connections trades a fast connection error for a slow memory-pressure outage on the primary.
The loop, answered
Every field is required, which is why no lesson here can recommend something without saying what it costs and what simpler thing to consider first.
- • Each execution environment is a separate process and constructs its own client and pool at module initialization.
- • The platform creates environments to match concurrency, so connection demand equals concurrency × pool size, with no coordination between environments.
- • The database enforces a fixed backend limit; beyond it, new connections are refused immediately rather than queued.
- • A proxy terminates client connections cheaply and multiplexes statements or transactions onto a small, stable set of backend connections.
- • Transaction-level pooling returns a backend connection after each transaction, which is what makes the ratio high — and what breaks session-scoped features.
- • Own the pooling mode decision and audit the application for session-scoped assumptions before choosing transaction pooling.
- • Own reserved concurrency as a connection budget: the function's concurrency cap is the database's protection.
- • Own the proxy's availability — multi-zone, monitored, and included in failover testing, because it is now on every query path.
- • Own credential handling at the proxy: prefer short-lived, identity-issued credentials to a static password in an environment variable.
- • Own the retry policy; exponential backoff with jitter on connection errors is what stops a brownout becoming a self-sustaining storm.
- • Connection refused at the database under burst, surfacing as an error rate that rises with traffic and looks intermittent because warm invocations still work.
- • A retry storm from throttled invocations, each retry opening a fresh connection attempt and deepening the exhaustion.
- • Transaction-mode pooling silently breaking prepared statements or session settings, producing errors that only occur under multiplexing.
- • A single-zone proxy failing and taking every database query with it, including from functions in healthy zones.
- • Idle-timeout resets between a warm environment and the proxy, so the first query after a quiet period fails once and then succeeds.
- • Connections scale linearly with concurrency until a proxy breaks the coupling; after that they scale with the proxy's backend pool, which you choose.
- • The proxy has its own client-connection ceiling, so it moves the limit rather than removing it — size it against peak concurrency, not average.
- • Reserved concurrency is the cheapest scaling control here: capping executions caps connections without adding a component.
- • Read replicas help query throughput but do not help connection count unless traffic is actually routed to them.
- • The proxy is a new component inside the trust boundary; it must live in a private subnet and accept traffic only from the function's security group.
- • It is a natural place to enforce identity-based database authentication, replacing a shared static password with short-lived credentials. See Roles vs Static Keys.
- • A database connection string in a function environment variable is readable by anyone who can read the function configuration — use a secret store. See Secrets in Infrastructure.
- • Proxy logs record which identity connected and when, which is the audit trail a shared password destroys.
- • The proxy is a fixed always-on cost, which is a real dent in the scale-to-zero property of the rest of the design.
- • Managed proxies typically bill per capacity unit or per hour; a self-hosted pooler costs a small instance plus the operational work of running it.
- • The alternative cost is worse: an exhausted database causes failed requests, and a bigger database instance to raise the connection ceiling is a much larger fixed line item.
- • Reserved concurrency costs nothing and is frequently sufficient for a workload with modest peaks.
- • Database connection count and refused-connection count, plotted against function concurrency — the correlation is the diagnosis.
- • Proxy client connections versus backend connections, which is the multiplexing ratio actually being achieved.
- • Function throttle count, since capping concurrency deliberately shows up here rather than as database errors.
- • The signal that lies: function error rate alone. It rises smoothly and looks like a code problem, while the actual limit is a fixed number on a component nobody was watching.
- • Reserved concurrency with no proxy at all, when peak concurrency is modest — the simplest fix, and it costs nothing but headroom.
- • An HTTP data API, when the provider offers one: stateless requests remove connections from the design entirely, at the cost of the SQL client ecosystem.
- • A queue plus a small persistent worker for all database writes, when the work does not need to be synchronous — the pool goes back where pools work.
- • Not using functions for this path: an always-on container with a normal pool is often the right answer for a steady, database-heavy API, and needs none of this machinery.
- • Buys a stable connection topology; costs a fixed always-on component on the path of every query.
- • Transaction pooling buys a high multiplexing ratio; costs session-scoped database features and an audit of every query path.
- • Reserved concurrency buys database protection for free; costs throughput exactly when demand is highest.
- • An HTTP data API buys total simplicity; costs latency, tooling and transactional ergonomics.
What people believe, and what is true
Set the pool size to one and the problem goes away.
It reduces the multiplier but does not remove it. One connection per environment at a thousand concurrent environments is still a thousand connections.
Just raise max_connections.
Each backend connection costs memory and a process. Raising the ceiling converts a clean connection error into a memory-pressure incident on the primary.
The proxy makes the limit disappear.
It relocates the limit to a component that is cheap to scale and easy to observe. The proxy has its own client ceiling, and it is now a dependency of every query.