Tuning MongoDB maxPoolSize and waitQueueTimeoutMS
This guide is part of MongoDB Driver Connection Pools. Two settings decide how a MongoDB service behaves when demand exceeds its pool: how many connections it may open to each server, and how long an operation waits for one. Left at their defaults, the first is far larger than most services need and the second does not exist at all, so a saturated pool produces operations that wait until server selection gives up:
MongoServerSelectionError: Server selection timed out after 30000 ms
MongoWaitQueueTimeoutError: Timed out while checking out a connection from connection pool:
maxPoolSize: 100, connections in use: 100
The objective is a pool sized from measured concurrency, a checkout wait bounded well inside the request deadline, and a fleet total that fits the cluster’s connection limit with room for reconnects.
Rapid incident diagnosis
Establish whether the pool is the constraint. Subscribe to the pool events for a minute, or read them from existing metrics:
let checkedOut = 0, failed = 0, created = 0;
client.on("connectionCheckedOut", () => checkedOut++);
client.on("connectionCheckedIn", () => checkedOut--);
client.on("connectionCheckOutFailed", (ev) => { failed++; console.log("reason:", ev.reason); });
client.on("connectionCreated", () => created++);
setInterval(() => console.log({ checkedOut, failed, created }), 5000);
checkedOut sitting at maxPoolSize with failed increasing and reason timeout is pool saturation. created climbing at the same time means the pool is still establishing connections, and the constraint may be maxConnecting rather than maxPoolSize.
Check the server’s side. serverStatus shows what the deployment sees, and whether it has room:
const c = db.adminCommand({ serverStatus: 1 }).connections;
// { current: 780, available: 720, totalCreated: 41233, active: 41 }
available close to zero means the cluster’s limit, not the pool, is about to bite — raising maxPoolSize would make it worse.
Separate slow operations from too few connections. If the pool is full and operations are individually fast, more connections will help. If operations have got slower, the pool fills because each connection is held longer:
db.getSiblingDB("admin").aggregate([
{ $currentOp: { allUsers: true, idleConnections: false } },
{ $match: { active: true, secs_running: { $gte: 1 } } },
{ $project: { ns: 1, op: 1, secs_running: 1, "command.filter": 1, planSummary: 1 } },
{ $sort: { secs_running: -1 } }, { $limit: 10 },
]).toArray();
COLLSCAN in planSummary on a hot query is the usual cause of a sudden rise in hold time; the pool is then a symptom.
Confirm which pool. Because pools are per server, saturation is often one-sided — the primary’s pool is full while secondaries idle. Group the events by ev.address before concluding anything about size.
Mathematical sizing or parameter formula
Pool size per server. Operations in flight against one server, divided by a utilisation target:
ops_in_flight = operations_per_second_to_this_server × mean_operation_seconds
maxPoolSize = ceil( ops_in_flight ÷ 0.7 )
A service issuing 4,000 operations per second to the primary, each taking 1.5 ms on the server plus 0.4 ms of network time, has 4000 × 0.0019 = 7.6 in flight; maxPoolSize = 11. Round up for burstiness, not for comfort: every extra connection is one the cluster must accept from every process.
Fleet total against the cluster limit. Include monitoring connections, which are roughly two per server per client:
fleet = processes × servers × maxPoolSize + processes × servers × 2
Twenty processes against a three-member replica set at maxPoolSize = 11: 20 × 3 × 11 = 660 pooled, plus about 120 monitoring, so 780. Against a tier allowing 1,500 connections that leaves room for a rolling deploy, which briefly doubles process count for one service.
Checkout wait. The wait must fit inside the operation’s deadline with room for the operation itself:
wait_budget = request_deadline − p99_operation_time − application_work − margin
With a 3-second request deadline, 60 ms of p99 operation time and 300 ms of other work, a 2-second bound is safe. Set it with timeoutMS on modern drivers — which covers selection, checkout and execution together — or waitQueueTimeoutMS where timeoutMS is unavailable.
Queue length is implied. Unlike SQL pools, MongoDB drivers do not expose a maximum queue length; the bound is time, not depth. The practical queue limit is therefore maxPoolSize × wait_budget ÷ mean_hold_time; beyond that, every additional waiter is guaranteed to time out. With 11 connections, a 2-second wait and a 2 ms hold, about 11,000 operations can be served within the window — far more than a saturated service will have queued, which means timeouts indicate a genuine shortfall rather than an unlucky queue.
Exact remediation & configuration
Set the pool and the deadlines explicitly. In Node.js, with the unified timeout:
export const client = new MongoClient(process.env.MONGODB_URI, {
maxPoolSize: 11,
minPoolSize: 2,
maxConnecting: 2,
maxIdleTimeMS: 60_000,
timeoutMS: 2_000, // selection + checkout + execution
serverSelectionTimeoutMS: 5_000,
connectTimeoutMS: 5_000,
appName: "orders-api",
retryReads: true,
retryWrites: true,
});
In drivers without timeoutMS, bound the checkout directly:
client = MongoClient(
os.environ["MONGODB_URI"],
maxPoolSize=11, minPoolSize=2, maxIdleTimeMS=60_000,
waitQueueTimeoutMS=2_000, serverSelectionTimeoutMS=5_000, appname="orders-api",
)
Give heavy work its own client. Analytics, exports and migrations have different concurrency and latency profiles from request traffic, and sharing one pool lets a report starve the API. A second client with its own small pool and a read preference for secondaries isolates them:
export const reporting = new MongoClient(process.env.MONGODB_URI, {
maxPoolSize: 4,
readPreference: "secondaryPreferred",
timeoutMS: 30_000, // reports may legitimately take longer
appName: "orders-reporting",
});
Both clients count against the cluster limit, so subtract the second pool from the first service’s budget rather than adding it on top.
Convert saturation into a fast, typed failure. Map checkout timeouts to a retryable response rather than letting them surface as generic errors:
import { MongoServerSelectionError } from "mongodb";
app.use((err, _req, res, next) => {
const msg = String(err?.message ?? "");
if (err instanceof MongoServerSelectionError || msg.includes("Timed out while checking out")) {
res.set("Retry-After", "1").status(503).json({ error: "database busy" });
return;
}
next(err);
});
Roll the change out one service at a time. Lowering maxPoolSize from the default is safe — it cannot exceed what the service was already allowed to open — but raising it changes the cluster’s exposure. Apply it to one deployment, watch connections.current and available for a peak period, then continue.
Keeping the size right as traffic changes
A pool sized once drifts. Three inexpensive habits keep it matched without re-deriving the formula every month.
Track utilisation, not just failures. Checked-out connections divided by maxPoolSize, per server address, is the leading indicator. Sustained peaks above 80 % mean the next traffic increment will queue; peaks below 20 % mean connections are reserved that other services could use:
setInterval(() => {
for (const [address, n] of inUse) {
metrics.poolUtilisation.labels(address).set(n / 11);
}
}, 10_000);
Re-derive after any change to the operation mix. A new aggregation in a hot path, an added index that halves query time, or a read-preference change that moves traffic to secondaries all change operations in flight. The formula’s inputs come from the same metrics you already collect: operation rate and mean latency per server.
Watch the cluster’s own headroom. connections.available falling over weeks — as services are added or replicas scale — is the fleet-level signal. It should be reviewed alongside the per-service pools, because the fix may be to reduce a different service’s pool rather than the one that noticed.
| Signal | Source | Healthy | Action if breached |
|---|---|---|---|
| Pool utilisation per server | connectionCheckedOut minus connectionCheckedIn |
peaks below 80 % | Re-derive maxPoolSize from current load |
| Checkout failures | connectionCheckOutFailed |
zero | Page; size or fix operations |
Cluster connections.available |
serverStatus |
above 30 % of the limit | Reduce pools or move to a larger tier |
| Mean operation time | driver command metrics | stable | Investigate the slowed operation before resizing |
Validation & verification
The pool is the size you configured. Under load, checked-out connections must peak at or below maxPoolSize per server, and the cluster’s view must match the fleet arithmetic:
db.adminCommand({ serverStatus: 1 }).connections.current // ≈ processes × servers × maxPoolSize + monitoring
Saturation fails fast. In staging, set maxPoolSize: 1 and issue 50 concurrent operations that each take 500 ms. Every operation beyond the first few must fail within about the configured wait, with a 503 rather than a hang:
hey -n 50 -c 50 https://staging-orders.internal/orders/42 | grep -E "\[503\]|\[200\]|Slowest"
No silent 30-second waits. Confirm that no operation takes longer than the configured bound by checking the p99 of request duration during the saturation test; a p99 near 30 seconds means neither timeoutMS nor waitQueueTimeoutMS is in effect.
Headroom survives a deploy. Trigger a rolling restart under load and watch connections.current. The peak must stay below the cluster limit with margin; if it does not, reduce maxPoolSize or the surge setting rather than relying on the deploy being quick.
Frequently Asked Questions
Is waitQueueTimeoutMS still supported?
timeoutMS, which applies one deadline to server selection, checkout and execution. Where both exist, timeoutMS is the clearer control; setting both can produce confusing interactions.Should maxPoolSize differ per server?
Does a bigger pool improve throughput on a single-core-bound cluster?
How does this interact with srvMaxHosts?
srvMaxHosts limits how many hosts the driver will connect to when resolving an SRV record, which directly limits the number of pools. It is a useful second control for sharded deployments with many mongos routers.Related
- MongoDB Driver Connection Pools — the parent guide to the driver pool model.
- Monitoring MongoDB Driver Pool Events — the events used for the metrics above.
- Diagnosing MongoDB Connection Storms After Failover — why a sized pool also recovers better.
- Applying Little’s Law to Connection Pool Sizing — the arithmetic behind operations in flight.