Sizing MongoDB Connection Pools for Serverless Functions

This guide is part of MongoDB Driver Connection Pools. A function platform multiplies everything a MongoClient does. Each execution environment that runs the handler holds its own client, its own topology monitor, and its own pool for every server in the deployment. With the default maxPoolSize of 100 and a three-member replica set, a single warm environment can hold 300 connections, and a spike to 200 concurrent environments asks the cluster for tens of thousands:

MongoServerSelectionError: connection  to 10.2.0.7:27017 closed
{"errmsg":"connection accepted but closed: too many open connections","code":291}

The objective is one client per execution environment, a pool of one or two connections per server, an explicit cap on concurrency, and a client that is never closed inside the handler.

Rapid incident diagnosis

Count environments, not requests. Connections scale with concurrent execution environments. On AWS Lambda:

aws cloudwatch get-metric-statistics --namespace AWS/Lambda --metric-name ConcurrentExecutions \
  --dimensions Name=FunctionName,Value=orders-fn --statistics Maximum \
  --start-time $(date -u -d '-1 hour' +%FT%TZ) --end-time $(date -u +%FT%TZ) --period 60 \
  --query 'Datapoints[].{t:Timestamp,max:Maximum}' --output table

Multiply the peak by servers × maxPoolSize and compare with the cluster’s limit. If the product exceeds it, the incident is arithmetic rather than a bug.

Check where the client is constructed. A client created inside the handler is the single most damaging pattern, because every invocation performs topology discovery, opens monitoring connections and starts a fresh pool:

grep -rn "new MongoClient\|MongoClient(" --include=*.js --include=*.ts --include=*.py src/ | grep -v "^src/db"

Anything inside a handler function, a route module or a request-scoped factory is the problem.

Look for close() in the handler. Closing the client at the end of an invocation forces the next invocation on the same warm environment to reconnect, which converts a warm path into a cold one and multiplies handshakes:

grep -rn "client.close()\|\.close()" --include=*.js --include=*.ts src/ | grep -i handler

Read the cluster’s acceptance errors. connection accepted but closed: too many open connections or an Atlas connection limit alert confirms the cluster is refusing, at which point every retry adds load. Check connections.available:

db.adminCommand({ serverStatus: 1 }).connections;   // { current, available, totalCreated }
Connections from serverless functions at scale With 200 concurrent environments against a three-member replica set, a default maxPoolSize of 100 implies up to 60,000 connections, while a pool of 1 per server implies 600 plus monitoring connections. 200 concurrent environments × 3 servers maxPoolSize 100 up to 60,000 — far beyond any cluster limit maxPoolSize 1 600 + monitoring typical cluster limit the pool that works in a long-lived service is the wrong pool in a function
Connections scale with concurrent environments, not with request rate. The default pool multiplied by servers and environments is what exhausts the cluster.

Mathematical sizing or parameter formula

Pool size per environment. A function instance usually handles one invocation at a time, and one invocation rarely runs parallel operations:

maxPoolSize = max(1, parallel_operations_per_invocation)

Use 1 for handlers that await operations sequentially, 2 where a handler deliberately runs two queries with Promise.all. Anything larger reserves connections that a single-invocation environment cannot use.

Fleet ceiling. Include monitoring connections, which are roughly two per server per client and are not covered by maxPoolSize:

fleet = max_concurrency × servers × (maxPoolSize + 2)
max_concurrency ≤ (cluster_limit × safety) ÷ (servers × (maxPoolSize + 2))

With a cluster limit of 1,500, a safety factor of 0.6, three servers and maxPoolSize = 1: max_concurrency ≤ (1500 × 0.6) ÷ (3 × 3) = 100 environments. If the workload needs more concurrency than that, the answer is not a bigger pool but fewer servers in the client’s view — connecting to a load-balanced endpoint — or a smaller share of the cluster per environment.

Cold-start cost. Each new environment pays topology discovery, a TLS handshake and authentication before its first operation:

cold_start_db_latency ≈ dns + tls_handshake + auth + topology_scan

Typically 40–150 ms in the same region, and much more across regions. Reducing it is a matter of placement and of doing the connect during initialisation rather than inside the first operation.

Idle environments still hold connections. A frozen environment keeps its sockets open until the platform reclaims it, so the fleet’s connection count decays slowly after a spike. Plan the ceiling for the peak, not the average, and set maxIdleTimeMS so that a long-frozen environment’s connections are eventually released rather than held until the socket is reclaimed.

Permitted concurrency for a given cluster limit At a 1,500 connection limit with a 60 percent safety factor, three servers and a pool of one plus two monitoring connections, the fleet may run about 100 concurrent environments. cluster limit 1,500 × 0.6 ÷ (3 servers × 3 connections) 1,500 cluster limit × 0.6 safety factor ÷ 9 per environment = 100 environments raise the ceiling by reducing servers in view or by proxying, not by enlarging the pool and cap the platform's concurrency to the number this produces
Concurrency is the lever. With the pool at one per server, the ceiling on concurrent environments follows directly from the cluster's connection limit.

Exact remediation & configuration

Construct the client once, at module scope, and reuse it. In Node.js on Lambda:

// db.js — module scope runs once per execution environment
import { MongoClient } from "mongodb";

const client = new MongoClient(process.env.MONGODB_URI, {
  maxPoolSize: 1,
  minPoolSize: 0,
  maxIdleTimeMS: 60_000,
  serverSelectionTimeoutMS: 5_000,
  connectTimeoutMS: 5_000,
  timeoutMS: 3_000,
  appName: "orders-fn",
  retryReads: true,
  retryWrites: true,
});

// Start connecting during initialisation; reuse the promise on warm invocations
const ready = client.connect();

export async function db() {
  await ready;
  return client.db("orders");
}
// handler.js
import { db } from "./db.js";

export const handler = async (event) => {
  const orders = await db();
  const order = await orders.collection("orders").findOne({ _id: event.orderId });
  return { statusCode: order ? 200 : 404, body: JSON.stringify(order ?? {}) };
  // no client.close(): the next invocation on this environment reuses the connection
};

In Python on Lambda, the same shape with a module-level client:

import os
from pymongo import MongoClient

client = MongoClient(
    os.environ["MONGODB_URI"],
    maxPoolSize=1, minPoolSize=0, maxIdleTimeMS=60_000,
    serverSelectionTimeoutMS=5_000, connectTimeoutMS=5_000, appname="orders-fn",
)
orders = client["orders"]

def handler(event, context):
    doc = orders["orders"].find_one({"_id": event["orderId"]})
    return {"statusCode": 200 if doc else 404}

Cap concurrency at the platform. The pool bounds one environment; only the platform bounds how many environments exist:

aws lambda put-function-concurrency --function-name orders-fn --reserved-concurrent-executions 100

On Cloud Run, the equivalent controls are --max-instances together with --concurrency, as described in Managing Connection Limits in Cloud Run and Cloud Functions.

Do not disable retryable operations. A function that fails an invocation because of a transient election forces a retry of the whole invocation, which costs another cold start’s worth of work. Retryable reads and writes absorb the election inside the driver.

Keep the function in the same region and VPC as the cluster. Cross-region connections multiply every cold start’s handshake cost and make the storm after a scale-out longer.

Where concurrency must exceed the ceiling, the options are to reduce the number of servers the driver connects to — a load-balanced endpoint presents one address instead of three — or to move the database access behind a small always-on service that holds a proper pool, with the functions calling it over HTTP. Both trade a hop for a bounded connection count.

Cold starts, timeouts and platform behaviour

Three platform behaviours interact with the pool and are worth handling explicitly.

Frozen environments. Between invocations the platform freezes the environment, suspending timers. Heartbeats do not run, so the driver cannot notice that a connection died while frozen; the failure surfaces on the next invocation as a selection or socket error. Retryable reads and writes cover most of it, and serverSelectionTimeoutMS of a few seconds bounds the rest. This is also why maxIdleTimeMS should be set: a connection idle long enough is replaced rather than used and found dead.

Initialisation timeouts. Starting the connection during module initialisation improves warm-path latency, but a cluster that is unreachable at that moment can make the environment fail to initialise. Awaiting the connect promise inside the handler — as in the example above — keeps the benefit while letting the first invocation surface a clear error rather than an initialisation failure.

Execution timeouts shorter than the driver’s. If the function’s timeout is 3 seconds and serverSelectionTimeoutMS is 30, the platform kills the invocation before the driver reports anything useful, and the logs contain a timeout with no cause. Keep the driver’s deadlines comfortably inside the function’s:

timeoutMS + connect_overhead < function_timeout − handler_work
Platform behaviour Effect on the pool Setting that handles it
Environment freeze Connections may die unnoticed maxIdleTimeMS, retryable operations
Environment reclaim Connections closed by the platform Nothing needed; count decays
Scale-out burst Many new pools at once Concurrency cap, small maxPoolSize
Short function timeout Driver errors never surface timeoutMS, serverSelectionTimeoutMS inside it
Provisioned or warm instances Connections held permanently Include them in the fleet ceiling
Client lifetime across invocations in one environment The first invocation pays initialisation and a handshake; subsequent warm invocations reuse the same connection. Closing the client in the handler would force every invocation to repeat the handshake. One execution environment over time cold start: connect warm warm warm warm with client.close() in the handler, every one of these becomes a cold connect and the cluster sees a new connection per invocation
The connection is established during initialisation and reused across warm invocations. Only a cold start pays the handshake, which is why the client must never be closed in the handler.

Validation & verification

Connections scale with concurrency as predicted. Drive the function to its concurrency cap in staging and compare the cluster’s count with the formula:

db.adminCommand({ serverStatus: 1 }).connections.current;
// expect ≈ concurrency × servers × (maxPoolSize + 2)

Warm invocations do not reconnect. Invoke the function 50 times sequentially — which keeps one environment warm — and confirm totalCreated on the cluster increases by roughly the number of servers, not by 50:

const before = db.adminCommand({ serverStatus: 1 }).connections.totalCreated;
// run 50 sequential invocations
const after = db.adminCommand({ serverStatus: 1 }).connections.totalCreated;
print(after - before);   // small, not ~50

The concurrency cap holds. Send a burst far above the cap and confirm the platform throttles rather than the database refusing: Lambda Throttles above zero and no too many open connections in the cluster’s log.

Errors are bounded and clear. With the cluster made unreachable in staging, invocations must fail within serverSelectionTimeoutMS with a selection error, not hang until the function timeout.

Frequently Asked Questions

Should minPoolSize be set in a function?
No. A minimum opens connections eagerly in every environment, including ones that will handle a single invocation and be frozen. Zero is right; the first operation opens the one connection needed.
Is it safe to share one client across handlers in the same file?
Yes, and it is the goal: one client per environment, shared by every handler in that environment. Sharing is safe because the driver is thread-safe and concurrency inside one environment is limited.
What about connecting through a load-balanced endpoint?
In load-balanced mode the driver keeps a single pool for the balancer address rather than one per server, which divides the per-environment connection count by the number of servers. It is the most effective lever when concurrency must be high.
Do transactions work with a pool of one?
Yes, but a transaction holds the connection for its duration, so a handler running a transaction and another operation concurrently would deadlock against its own pool of one. Keep such handlers sequential, or set maxPoolSize: 2.