Serverless Function Connection Management

This guide is part of Cloud Database Connection Management. Serverless platforms break the central assumption every connection pool is built on: that a small, stable set of long-lived processes serves a large amount of traffic. In a function environment the process count is elastic, its lifetime is measured in minutes, and it is frozen between invocations — so a pool that works perfectly in a container behaves in ways that look like driver bugs.

The result is that serverless connection management is less about tuning a pool and more about deciding where pooling should happen at all. This guide covers the execution-environment lifecycle, why per-environment pools must be tiny, the freeze problem that no pool timer survives, and the patterns — proxy, data API, or connection reuse — that keep function concurrency decoupled from backend count.

Key operational takeaways:

  • A function environment serves one request at a time, so a pool larger than one is unusable except in handlers that fan out queries in parallel.
  • Backend count is concurrent_environments × pool_size, and concurrency is set by the platform in response to traffic — not by you.
  • Environments freeze between invocations: timers do not run, so idle timeouts and keepalives cannot fire and the pool cannot notice a reaped socket.
  • Creating the client at module scope is correct and necessary; creating it inside the handler defeats reuse entirely.
  • A transaction-mode proxy is the only mechanism that decouples function concurrency from database connection count.
Execution environment lifecycle An environment is initialised once, serves a sequence of invocations with frozen periods between them, and is eventually reclaimed without any shutdown hook running. One execution environment, over its whole life init module scope runs once invocation 1 one request only frozen no CPU, no timers invocation 2 socket may be dead reclaimed no shutdown hook what the freeze breaks idle timeout cannot fire — no timers run keepalive cannot fire — no CPU is scheduled a reaped socket is discovered by using it so the first query after a gap can fail what reclamation breaks no SIGTERM, no shutdown hook, no pool.end() the backend is left for the server to reap so a burst of reclamations leaves orphans a short server-side idle timeout is essential
Two properties of the environment lifecycle break pool assumptions: nothing runs while frozen, and nothing runs at reclamation. Both have to be compensated for on the server side.

Foundational Mechanics

The single most important fact is that a function execution environment handles one request at a time. Platforms achieve concurrency by running many environments, not by running many requests in one. That inverts the usual sizing logic: a pool exists to let concurrent work share connections, and there is no concurrent work to share.

The consequence is that pool size should be one, in almost every handler. A second connection cannot be used, because there is no second request to use it — it exists only to consume a slot in the database’s connection budget that another environment needs. The exception is a handler that deliberately fans out several queries in parallel, which does need one connection per branch for the duration of the fan-out.

Module-scope initialisation is the mechanism that makes reuse possible. Code outside the handler function runs once per environment, during initialisation, and its results persist across invocations while the environment stays warm. A client created there is reused; a client created inside the handler is created and discarded on every invocation, which converts every request into a full handshake and defeats the point entirely.

// Module scope — runs once per environment, reused across invocations.
import { Pool } from 'pg';

const pool = new Pool({
  max: 1,                          // one request per environment; more is unusable
  connectionTimeoutMillis: 2000,
  idleTimeoutMillis: 0,            // pointless here — timers do not run while frozen
  allowExitOnIdle: false,
});

export async function handler(event) {
  // Handler scope — runs per invocation, reuses the pool above.
  const { rows } = await pool.query('SELECT id FROM orders WHERE ref = $1', [event.ref]);
  return { statusCode: 200, body: JSON.stringify(rows) };
}

The idleTimeoutMillis: 0 in that example is deliberate and slightly counter-intuitive. An idle timeout cannot fire while the environment is frozen, so configuring one accomplishes nothing except closing the connection during the brief moments the environment is awake — which is precisely when it is wanted. Connection lifetime in serverless is governed by the platform and by the server-side idle timeout, not by the client.

Operational Boundary: Connection handling within a function environment is covered here. Platform concurrency limits, reserved concurrency and cold-start optimisation are runtime concerns that determine the multiplier this section takes as input.

Precision Sizing & Timeout Orchestration

Sizing is a two-term product, and only one term is under your control.

backends = concurrent_environments × pool_size_per_environment

Concurrent environments is set by the platform in response to traffic. On Lambda it is bounded by reserved or account concurrency; on Cloud Run by --max-instances multiplied by whether concurrency-per-instance is greater than one; on edge platforms it may be effectively unbounded. Pool size is yours, and with the first term potentially in the hundreds, the second must be one.

Even then the arithmetic frequently fails. Two hundred concurrent Lambda environments with a pool of one is 200 backends — more than a db.t3.medium can accept, and a substantial share of anything smaller than a db.r6g.xlarge. The platform’s own scaling is what exhausts the database, and no client-side setting prevents it.

That leaves three structural options, and choosing between them is the real decision this page exists to support.

Approach Backends Latency Cost When It Fits
Direct, pool of 1 = concurrency None Low, bounded concurrency only
Reserved concurrency cap = the cap None; requests beyond the cap are throttled Concurrency can be bounded without harming the product
Transaction-mode proxy Bounded by proxy pool 1–3 ms per query The general answer
HTTP data API Zero persistent Higher per query Very spiky, very low volume

Reserved concurrency deserves more attention than it usually gets. Capping a function’s concurrency at a number the database can serve converts an unbounded database problem into a bounded throttling problem — and a throttled request that retries is a much better outcome than a database that stops accepting connections for every service sharing it. It costs nothing and takes one configuration line.

Operational Boundary: The connection arithmetic and the structural options are covered here. Choosing a concurrency cap that balances product requirements against database capacity is a capacity-planning decision made jointly with the service owner.

Diagnostics & Telemetry

The observability problem in serverless is that the client side barely exists. There is no long-lived process to scrape, pool gauges describe one environment out of hundreds, and by the time an incident is investigated the environments involved have been reclaimed.

That makes server-side and platform-side signals the primary evidence.

Signal Where What It Tells You
Backend count by application_name pg_stat_activity Actual connections from the function fleet
Connection establishment rate Database logs or metrics Whether environments are reusing or reconnecting
Concurrent executions Platform metrics The multiplier in the sizing arithmetic
Init duration Platform traces Whether the handshake is on the cold path
Throttled invocations Platform metrics Whether the concurrency cap is binding
Proxy pinned connections Proxy metrics Whether multiplexing is actually happening

The most useful single number is the ratio of connection establishments to invocations. In a healthy warm fleet it is far below one — most invocations reuse an existing connection. A ratio near one means environments are not reusing, which points either at a client created inside the handler or at an environment lifetime so short that reuse never happens.

Logging from module scope is the cheap way to measure that. A single log line emitted during initialisation, counted against invocation count, gives the environment-reuse ratio without any additional instrumentation.

Establishment rate reveals whether reuse is working In a warm fleet the connection establishment rate is far below the invocation rate. When the client is constructed inside the handler the two rates track each other exactly. 0 per second invocations connections opened — warm fleet, reuse working client built inside the handler time → The red line tracking invocations exactly is the signature of a client constructed per invocation rather than at module scope
The ratio of connection establishments to invocations is the cheapest available diagnostic, and a ratio near one identifies the most common serverless mistake immediately.

Handling the Freeze

The environment freeze is the property with no analogue in any other deployment model, and the one that produces the most confusing failures. Between invocations the environment’s CPU is descheduled entirely: no timer fires, no keepalive is sent, no background thread runs. A connection that the pool believes is healthy may have been closed by the database, a NAT gateway, or a proxy minutes earlier, and the pool has no way to have noticed.

The failure surfaces as an error on the first query of an invocation following a gap — Connection terminated unexpectedly, server closed the connection unexpectedly, or a driver-specific equivalent. It is intermittent by nature, it correlates with traffic gaps rather than with load, and it is frequently misdiagnosed as a database problem because the database is entirely healthy.

Three mitigations, in order of preference:

Retry once on a connection-level error. The cheapest and most reliable. A connection error on the first query of an invocation is almost always a stale socket; opening a fresh connection and retrying succeeds immediately. Scope the retry narrowly — connection errors only, one attempt, and never around a non-idempotent write that may already have been applied.

Validate before use when the gap was long. Some drivers support a cheap liveness check on checkout. In a function this costs a round trip on every invocation, which is significant when invocations are short — so it is worth it only if connection errors are frequent enough to outweigh the added latency.

Keep the server-side idle timeout short. This does not prevent the client-side failure, but it bounds the orphaned-backend problem from the other direction: environments reclaimed without closing their connections leave backends that only a server-side timeout will reap.

// A connection error on the first query of an invocation is almost always a stale socket.
const CONNECTION_ERRORS = new Set([
  'ECONNRESET', 'EPIPE', 'ETIMEDOUT',
  '57P01',  // admin_shutdown
  '08006',  // connection_failure
]);

async function queryWithOneRetry(sql, params) {
  try {
    return await pool.query(sql, params);
  } catch (err) {
    if (!CONNECTION_ERRORS.has(err.code)) throw err;
    // The pool discards the broken connection; the retry opens a fresh one.
    return await pool.query(sql, params);
  }
}

The narrowness of that retry matters. Retrying every error turns a deterministic failure — a constraint violation, a syntax error — into two identical failures and doubles the load during an incident. Classifying on the error code rather than retrying broadly is what makes the pattern safe.

Operational Boundary: Client-side handling of the freeze is covered here. Platform decisions about environment lifetime, warm pools and provisioned concurrency are runtime configuration that changes how often this path is taken.

Integration & Proxy Compatibility

Every managed proxy option operates in transaction mode, which means the session-state audit described in Transaction vs Statement Pooling Trade-offs applies in full — session SET statements, named prepared statements, session advisory locks and LISTEN all need attention before the proxy delivers what it promises.

Two serverless-specific integration details matter beyond that audit.

IAM authentication changes the cost of a cold connection. On RDS Proxy with IAM auth, establishing a connection involves a token exchange, which adds tens of milliseconds. On a long-lived container that cost is amortised across thousands of requests; on a fleet of short-lived environments it is paid far more often. It remains worth it for the credential-management benefit, but it belongs in the cold-start budget.

The proxy must be reachable from the function’s network configuration. A Lambda in a VPC reaching RDS Proxy is straightforward; a Lambda outside a VPC cannot reach it at all, and the VPC attachment itself adds to cold-start time on older runtimes. On Cloud Run the equivalent question is whether the connector or a private IP is in use, and whether the pooler is reachable from the revision’s egress settings.

Provider-native poolers deserve a specific mention because they change the calculus for newer platforms. Neon, Supabase and several other managed PostgreSQL providers expose a pooled endpoint — typically PgBouncer in transaction mode — alongside the direct one. Using the pooled host is a connection-string change rather than an infrastructure project, and for serverless workloads on those platforms it is the default correct choice.

Three approaches as concurrency rises Direct connections rise linearly with function concurrency and cross the database limit. A reserved-concurrency cap flattens at the cap. A transaction-mode proxy flattens at its own pool size regardless of concurrency. 0 backends database limit direct — linear in concurrency reserved concurrency cap transaction-mode proxy 50 200 600 concurrent function environments — set by the platform, not by you
Only the proxy decouples the two axes entirely. The concurrency cap bounds the problem without solving it, which is often enough and always cheaper.

Common Failure Patterns & Remediation

Symptom Root Cause Exact Fix Validation
too many clients already under traffic growth Concurrency × pool exceeds the limit Pool of 1 plus a transaction-mode proxy Backends flat as concurrency rises
Every invocation shows handshake latency Client constructed inside the handler Move construction to module scope Establishment rate falls far below invocation rate
First query after a quiet period fails Socket reaped while the environment was frozen Retry once on a connection error; short server-side idle timeout Errors absent across a quiet cycle
Backends linger after traffic stops No shutdown hook runs at reclamation Server-side idle_session_timeout Backend count decays after traffic stops
Proxy deployed, backend count unchanged Session pinning Audit for SET, temp tables, prepared statements Pinned-connection metric returns to zero
Cold starts slow after adding a VPC Network attachment plus handshake on the cold path Keep the pool warm, or use provisioned concurrency Init duration returns to the previous range

Frequently Asked Questions

Is a pool of one really correct?
For a handler serving one request at a time, yes. The only case for more is a handler that issues several queries in parallel with Promise.all or equivalent, which genuinely needs one connection per branch. If the handler is sequential, a second connection can never be in use.
Does provisioned concurrency solve the connection problem?
It changes its shape. Pre-warmed environments avoid cold-start handshakes and make the concurrent-environment count more predictable, which makes the arithmetic tractable — but the count is still the multiplier, and a high provisioned concurrency holds that many connections continuously rather than only under load.
Should the handler close the connection at the end?
No. Closing it discards the reuse that module-scope initialisation exists to provide, turning every invocation into a cold connection. Let the connection persist with the environment; the platform reclaims it when the environment is reclaimed.
How do HTTP data APIs compare?
They remove persistent connections entirely — each query is an HTTP request against a managed endpoint that pools on the server side. That eliminates the connection-count problem completely, at the cost of higher per-query latency and a more limited feature surface, particularly around transactions. For spiky, low-volume workloads the trade is often good.
What about scheduled functions that run rarely?
They are the worst case for reuse, because every invocation is effectively a cold start and every one pays a full handshake. A pool is pointless there; what matters is that the connection is closed or short-lived enough that a rarely-running function does not hold a backend between executions.
Does a container-based platform like Cloud Run behave the same way?
Only partly. Cloud Run instances can serve several concurrent requests, which restores the case for a pool larger than one — sized to the per-instance concurrency setting rather than to one. They also receive a termination signal, so a shutdown hook can close the pool properly. What stays the same is that the instance count is elastic and set by the platform, so the multiplication that exhausts a database is unchanged.
Should a function open its connection eagerly during initialisation?
Usually yes. Opening one connection at module scope moves the handshake into the initialisation phase, which most platforms bill differently and which is not on the request’s critical path. The exception is a rarely-invoked function, where an eagerly opened connection is simply held between invocations for no benefit.
How does a scheduled warm-up compare to provisioned concurrency?
A periodic invocation keeps a small number of environments warm, which helps a low-traffic function and does nothing for a spike. Provisioned concurrency pre-warms a specified number and is predictable, at the cost of holding that many connections continuously. Neither changes the fundamental arithmetic — they change which part of the curve you sit on.