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.

Diagnosing MongoDB checkout timeouts If checked-out connections equal maxPoolSize and operations are fast, the pool is too small. If operations have slowed, hold time is the cause. If connections are still being created, establishment is throttled. If the cluster has no available connections, the limit is the constraint. Checkout timeouts reason: timeout Pool and server state at that moment at maxPoolSize, ops fast: size the pool at maxPoolSize, ops slow: fix the query creating connections: raise maxConnecting cluster available near 0: tier limit group events by server address first
Checkout failures alone do not identify the cause. The pool's state and the operation profile at the same moment do.

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.

Fleet connections against the cluster limit Twenty processes against three servers hold 780 connections including monitoring when maxPoolSize is 11, within a 1,500 connection limit, with room for a rolling deploy that briefly adds a further 390. Connections against a 1,500 limit steady 780 deploy overlap +390 headroom cluster limit 1,500 maxPoolSize 100 instead of 11 would need 6,120 connections which is four times the limit, before any deploy overlap monitoring connections are included: about two per server per client
Sizing from operations in flight leaves the cluster's limit with headroom for deploy overlap, which the default does not.

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
Pool utilisation rising ahead of checkout failures Pool utilisation climbs past 80 percent several minutes before the first checkout failure, giving time to act if the utilisation series is being watched. pool utilisation checkout failures 80 % of maxPoolSize rising steadily failures start here 12:00 12:30
Utilisation is the leading signal. By the time checkout failures appear, requests are already being lost.

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?
It remains available in several drivers and continues to bound checkout, but newer drivers favour 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?
The setting is per client, not per server address, so all of a client’s pools share it. Where a workload is strongly primary-biased, size for the primary and accept that secondary pools rarely reach the limit.
Does a bigger pool improve throughput on a single-core-bound cluster?
No. Once the cluster is CPU-bound, more concurrent operations increase queueing inside the server and raise latency for everyone. The pool should cap concurrency somewhere below the point where added concurrency stops adding throughput.
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.