Serverless

Serverless and Database Connections

The arithmetic that breaks the first serious serverless deployment: 20 app servers held 20 connections; 1000 concurrent function environments want 1000, against a database that accepts 100. The proxy is the answer, and it is not free.

The question this answers

Infrastructure question

Why does a function that worked perfectly in staging exhaust the database the first time real traffic arrives?

Application requirement

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.

What it provides

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.

Application RequirementInfrastructure RequirementComputeNetworkStorageIdentityDeploymentScalingReliabilityObservabilitySecurityCostTrade-offs

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
Same code, two deployment models. ILLUSTRATIVE arithmetic.

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 pool lives in the component that does not scale per request.PROVIDER-NEUTRAL
Virtual network
Private subnet — zone Aprivate
Function environments ×Nprivate— N = concurrency; each opens a cheap proxy connection
Connection proxy / poolerprivate— many client connections in, few backend connections out
Private subnet — zone Bprivate
Managed PostgreSQL — primaryprivate— max_connections unchanged and unchangeable in practice
Function environments ×NConnection proxy / pooler· 1000 client connections
Connection proxy / poolerManaged PostgreSQL — primary· ~40 backend connections, reused

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.

A pool per environment — correct in a server, catastrophic in a function
// 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.
One connection per environment, aimed at a proxy that owns the real pool
// 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.

How it works
  • 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.
What you still own
  • 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.
How it fails
  • 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.
How it scales
  • 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.
Security
  • 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.
Cost shape
  • 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.
What to watch
  • 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.
Simpler alternatives
  • 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.
What adopting this costs
  • 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

Claim

Set the pool size to one and the problem goes away.

Reality

It reduces the multiplier but does not remove it. One connection per environment at a thousand concurrent environments is still a thousand connections.

Claim

Just raise max_connections.

Reality

Each backend connection costs memory and a process. Raising the ceiling converts a clean connection error into a memory-pressure incident on the primary.

Claim

The proxy makes the limit disappear.

Reality

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.

Apply it