Reusing Database Connections Across Lambda Invocations
This guide is part of Serverless Function Connection Management. A Lambda that opens a database connection inside its handler pays a full TCP, TLS and authentication handshake on every invocation — typically 20–80 ms, and considerably more with IAM authentication. Moving the client to module scope removes that cost for every invocation after the first in each environment, which on a warm function is the overwhelming majority.
The symptom of getting it wrong is latency that never improves with traffic. A function whose p50 stays at 90 ms regardless of how warm the fleet is, against a database whose query latency is 4 ms, is almost always reconnecting on each invocation.
REPORT RequestId: 8f2a… Duration: 94.21 ms Billed Duration: 95 ms Init Duration: 412.66 ms
REPORT RequestId: 9c1b… Duration: 91.48 ms Billed Duration: 92 ms
REPORT RequestId: a04c… Duration: 93.02 ms Billed Duration: 94 ms
# Init Duration appears once — the environment IS warm — yet Duration never falls.
The Init Duration line appearing only on the first report proves the environment is being reused. Duration staying flat proves the connection is not.
Rapid Incident Diagnosis
Three signals, in order.
1. Compare connection establishment rate to invocation rate. The cheapest diagnostic available. Log one line from module scope and one from the handler:
console.log(JSON.stringify({ evt: 'env_init', ts: Date.now() })); // module scope
export async function handler(event) {
console.log(JSON.stringify({ evt: 'invoke' })); // handler scope
// …
}
In a warm fleet, env_init should appear once per environment and invoke many times. A ratio near one means environments are not being reused at all — or, more commonly, that the client is being constructed per invocation and the initialisation log is misleading you.
2. Check the database’s connection rate. On PostgreSQL, enable log_connections briefly, or compare pg_stat_database.numbackends against the establishment count. A connection rate matching the invocation rate is the definitive confirmation.
3. Read Init Duration versus Duration. A high Init Duration on cold starts and a flat, elevated Duration afterwards is the signature. If Duration falls after the first invocation, reuse is working and the latency is elsewhere.
Exact Remediation and Configuration
The pattern is short, and every line in it matters.
// index.mjs — module scope runs once per execution environment.
import { Pool } from 'pg';
const pool = new Pool({
host: process.env.DB_HOST,
database: process.env.DB_NAME,
user: process.env.DB_USER,
max: 1, // one request per environment; more is unusable
connectionTimeoutMillis: 2000, // never leave this at 0
idleTimeoutMillis: 0, // timers cannot fire while frozen — do not bother
allowExitOnIdle: false, // keep the connection across invocations
});
// Idle-client errors are emitted here, not at the call site. Without this handler
// an unhandled 'error' event terminates the environment.
pool.on('error', (err) => console.error('pool error', err.message));
const RETRYABLE = new Set(['ECONNRESET', 'EPIPE', 'ETIMEDOUT', '57P01', '08006']);
export async function handler(event) {
try {
return await query(event);
} catch (err) {
if (!RETRYABLE.has(err.code)) throw err;
// The socket died while the environment was frozen. The pool discards it;
// this retry opens a fresh one.
return await query(event);
}
}
async function query(event) {
const { rows } = await pool.query(
'SELECT id, status FROM orders WHERE ref = $1', [event.ref]);
return { statusCode: 200, body: JSON.stringify(rows) };
}
max: 1 is deliberate. A Lambda environment serves one request at a time, so a second connection can never be used concurrently — 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 queries in parallel, which needs one per branch.
allowExitOnIdle: false keeps the connection alive with the environment. Setting it true lets the Node process exit when the event loop empties, which in a Lambda means the connection is closed between invocations and reuse is lost.
The retry exists because of the freeze. Between invocations the environment’s CPU is descheduled entirely, so no keepalive is sent and no idle timer fires. A socket the database or a NAT gateway closed during that gap is discovered by using it, and the first query of the next invocation fails. One retry on a connection-level error resolves it transparently; retrying broadly does not, and doubles load during a genuine outage.
pool.on('error') is not optional. Node terminates a process on an unhandled error event from an EventEmitter, and idle-client errors are emitted on the pool rather than at a call site. Without the handler, a reaped connection kills the environment.
Warming and the Cold-Start Trade
Opening the connection eagerly during initialisation moves the handshake off the request’s critical path, and on most platforms the initialisation phase is billed differently:
// Optional: connect during init so the first invocation does not pay for it.
const warm = pool.query('SELECT 1').catch((e) => console.error('warm failed', e.message));
export async function handler(event) {
await warm; // resolves immediately on every invocation after the first
return query(event);
}
This is worth doing for a function invoked frequently enough that most environments serve many requests. It is worth not doing for a rarely-invoked function, where the eagerly opened connection is simply held between distant invocations, consuming a backend for no benefit.
Provisioned concurrency changes the calculus in the same direction: pre-warmed environments never pay a cold handshake, but they hold their connections continuously rather than only under load. The connection count becomes predictable, which is an improvement, but it becomes a floor rather than a function of traffic.
Common Failure Patterns & Remediation
| Symptom | Root Cause | Exact Fix | Validation |
|---|---|---|---|
| Duration flat and high on a warm fleet | Client constructed inside the handler | Move construction to module scope | Duration falls after the first invocation |
| Environment terminates with no stack trace | Unhandled error event on an idle client |
pool.on('error', …) |
Init count stops tracking invocation count |
| First query after a quiet period fails | Socket reaped while the environment was frozen | One retry on a connection-level error | Errors absent across quiet cycles |
too many clients as traffic grows |
max above 1, multiplied by concurrency |
Set max: 1; add a transaction-mode proxy |
Backends flat as concurrency rises |
| Backends linger long after traffic stops | No shutdown hook runs at reclamation | Server-side idle_session_timeout |
Backend count decays after traffic stops |
| Connection reuse works locally, not deployed | allowExitOnIdle: true closing it between calls |
Set it false |
Establishment rate falls well below invocation rate |
Validation and Verification
The definitive check is the ratio of connection establishments to invocations, measured over a period long enough to include warm environments.
-- With log_connections on briefly, or from the metric:
SELECT count(*) AS backends,
sum(xact_commit + xact_rollback) AS transactions
FROM pg_stat_activity a
JOIN pg_stat_database d ON d.datname = current_database()
WHERE a.application_name = 'orders-fn'
GROUP BY d.datname;
A healthy warm fleet shows transactions far exceeding backend establishments. A ratio near one means each invocation is opening its own connection, whatever the code appears to do.
Then confirm the retry path works by forcing a stale socket: invoke the function, wait past the database’s idle_session_timeout, and invoke again. Without the retry the second invocation fails; with it, the failure is invisible and the connection is replaced.
Frequently Asked Questions
Does the same pattern apply to a RDSDataService data API client?
Should the connection be closed at the end of the handler?
Does this work the same in Python and Go?
init() in Go. The freeze and the retry requirement are properties of the platform, not the language.How does IAM authentication change the trade?
What if the function needs a transaction across several queries?
pool.connect() for that handler and release in a finally, exactly as in a long-running service. With max: 1 there is only one connection to take, so the pattern is safe — but a leak is fatal rather than gradual, because the environment has no second connection to fall back on.Related
- Serverless Function Connection Management — the parent guide on the execution-environment model.
- Managing Connection Limits in Cloud Run and Cloud Functions — the GCP equivalent, where per-instance concurrency changes the arithmetic.
- Sizing the node-postgres Pool for Serverless — the sizing derivation behind
max: 1. - AWS RDS Proxy Connection Pooling — the multiplexing layer that bounds the total.