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.

Module scope versus handler scope A client created at module scope is initialised once per environment and reused by every invocation. A client created inside the handler is constructed and discarded on every invocation, paying a handshake each time. module scope — one handshake per environment init + connect invoke invoke invoke … every subsequent invocation reuses the same connection handler scope — one handshake per invocation init connect + invoke connect + invoke connect + invoke connect + invoke The database also sees the difference Handler-scope construction produces a connection establishment rate equal to the invocation rate, which on a busy function is a load the database was never sized for
Module scope pays the handshake once per environment; handler scope pays it per invocation. The difference shows in latency at the client and in connection rate at the database.

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.

Latency across an environment's first invocations With handler-scope construction latency stays flat and high. With module scope the first invocation is slow and the rest are fast. With eager warming even the first invocation is fast. 0 100ms duration handler scope — flat and high forever module scope — first slow, rest fast warmed during init 1st 4th 7th invocation within one environment — the green and blue lines are identical after the first
Module scope fixes every invocation after the first; eager warming during initialisation fixes the first one too, at the cost of holding a connection in environments that may serve only one request.

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.

Why the retry is needed and where it goes During the frozen period the database closes the idle connection. The next invocation's first query fails, the pool discards the broken connection, and a single retry opens a fresh one transparently. invocation 1 connection healthy frozen — no timers, no keepalive server closes the idle socket here invocation 2 first query fails: ECONNRESET retry pool opens a fresh connection — invisible to the caller Classify on the error code — retrying broadly turns one deterministic failure into two ECONNRESET, EPIPE, 57P01 and 08006 are worth retrying; a constraint violation or syntax error never is
The retry exists for one narrow case: a socket closed while the environment could not notice. Scoping it to connection-level error codes keeps it safe.

Frequently Asked Questions

Does the same pattern apply to a RDSDataService data API client?
The client should still be at module scope for the same reason — it holds an HTTP connection pool and credentials. What changes is that there is no database connection to reuse or lose, so the freeze and retry considerations do not apply.
Should the connection be closed at the end of the handler?
No. Closing it discards exactly the reuse this pattern exists to provide. The platform reclaims the connection when it reclaims the environment; a short server-side idle timeout handles the orphan that leaves behind.
Does this work the same in Python and Go?
The mechanism is identical — the client goes outside the handler function, at module level in Python or in a package-level variable initialised in init() in Go. The freeze and the retry requirement are properties of the platform, not the language.
How does IAM authentication change the trade?
It raises the cost of each new connection substantially, because a token exchange is added to the handshake. That makes module-scope reuse more valuable, not less — and it makes the eager-warming option more attractive for functions that are invoked often.
What if the function needs a transaction across several queries?
Use 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.