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.
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.
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.
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?
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?
Should the handler close the connection at the end?
How do HTTP data APIs compare?
What about scheduled functions that run rarely?
Does a container-based platform like Cloud Run behave the same way?
Should a function open its connection eagerly during initialisation?
How does a scheduled warm-up compare to provisioned concurrency?
Related
- Cloud Database Connection Management — the parent reference on managed-database connection ceilings and layering.
- Reusing Database Connections Across Lambda Invocations — the module-scope pattern and the freeze problem in detail.
- Managing Connection Limits in Cloud Run and Cloud Functions — the GCP equivalents, where per-instance concurrency changes the arithmetic.
- Choosing a Connection Proxy for Serverless Postgres — selecting between the proxy options by platform.
- Sizing the node-postgres Pool for Serverless — the Node.js-specific configuration for the pattern described here.