A MongoDB driver doesn't open a connection per query — it holds a pool and checks them out. How big the pool is, how long checkouts wait, what write durability you ask for (write concern), what read freshness you tolerate (read concern), and where reads go (read preference) — these settings decide whether your app sustains 100 req/sec or 100k req/sec, and whether a failover costs you data. This post covers every knob with sensible production defaults.

Series map: 00 Foundations → … → 07 Schema Design → 08 Connection Pool & Concurrency (this post) → 09 Transactions → 10 Replica Sets → … → 17 Drivers & Case Studies.


Learning Outcome

  • Size a connection pool (maxPoolSize, minPoolSize, maxIdleTimeMS, waitQueueTimeoutMS).
  • Tune serverSelectionTimeoutMS, connectTimeoutMS, socketTimeoutMS.
  • Pick the right write concern (w, j, wtimeout) per workload.
  • Pick the right read concern (local, majority, snapshot, linearizable).
  • Pick the right read preference (primary, primaryPreferred, secondary, nearest).
  • Understand retryable writes + reads and when they help.
  • Use causal consistency sessions when needed.
  • Avoid the "too many connections" production outage.

Chapter 1 — The MongoClient lifecycle

A MongoClient is thread-safe, long-lived, and pooled. Create once per process; reuse for every operation.

1
2
3
4
5
6
7
8
9
10
11
12
13
// Node.js — correct pattern
import { MongoClient } from 'mongodb';
const client = new MongoClient(uri, {
maxPoolSize: 50,
minPoolSize: 5,
maxIdleTimeMS: 60000,
});
await client.connect();
export const db = client.db('shop');

// in many handlers later:
await db.collection('users').findOne({ ... }); // reuses the pool

Wrong pattern (anti-pattern): new MongoClient(uri) per request. Each instantiation opens new sockets, runs handshakes, and exhausts server-side connection limits. Across a fleet this is a #1 outage cause.

Same rule across languages — MongoClient (Node, Python sync), AsyncIOMotorClient (Python async), mongo.Client (Go), MongoClient (.NET) — all are pooled + thread-safe singletons.


Chapter 2 — Pool options

Set in the connection string or as client options.

Option Default Meaning
maxPoolSize 100 max concurrent connections per pool (per server/mongos)
minPoolSize 0 keep this many warm at idle
maxIdleTimeMS 0 (infinite) close connections idle this long
maxConnecting 2 max parallel new-connection handshakes
waitQueueTimeoutMS 0 (infinite) how long an operation waits for a connection before throwing
serverSelectionTimeoutMS 30000 how long the driver tries to find a suitable server
connectTimeoutMS 10000 socket connect timeout
socketTimeoutMS 0 (no timeout) inactivity socket timeout (rarely needed; let server time out instead)
heartbeatFrequencyMS 10000 how often driver polls topology

Connection string form

1
2
mongodb+srv://user:pw@cluster0.mongodb.net/shop?maxPoolSize=50&minPoolSize=5&maxIdleTimeMS=60000

Chapter 3 — Sizing the pool — the right answer is "not larger"

A common mistake: bump maxPoolSize to 500 or 1000 hoping for more throughput. The opposite usually happens — more connections = more context switches on server + WiredTiger ticket contention + memory bloat.

Useful starting numbers

Workload maxPoolSize
Small web app, dozens of QPS 10-20
Medium API, 1k-5k QPS 50-100 (default)
High concurrency, mostly fast queries 100-200
Heavy aggregations / long queries smaller pool + more app instances

Math — Little's Law

Avg concurrent ops needed = throughput (ops/sec) × avg latency (sec).

Example: 200 QPS × 0.05 s avg latency = 10 concurrent ops needed. A pool of 20 is plenty; 100 is wasted resources.

If your driver pool is much larger than the math suggests, the bottleneck is elsewhere (server, network, lock contention) and pool growth won't help.

Per process vs per fleet

Each Node process / each Python worker has its own pool. A web fleet of 20 instances × maxPoolSize: 50 = up to 1000 connections to the server. The MongoDB server has its own ceiling (maxIncomingConnections, default 65,536). Don't accidentally exceed it.

Atlas tier limits

  • M0 / M2 / M5 (shared): 500 connections.
  • M10: 1500.
  • M20: 3000.
  • M30+: 6000+ (scales with tier).

maxPoolSize × fleet_instances must stay well under your Atlas tier limit. Plan headroom for failover spikes.

Sizing the pool with Little's Law, and how a fleet of pools compares with each Atlas tier's connection limit

Trick: monitor connections.current (server metric). If it's near the pool max for long stretches, the pool is the bottleneck. If it's nowhere near, growing the pool doesn't help — fix the slow query instead (Post 04).


Chapter 4 — Write concern (w, j, wtimeout)

1
2
3
4
db.collection('orders').insertOne(doc, {
writeConcern: { w: 'majority', j: true, wtimeout: 5000 }
})
Field Values
w 0 (fire-and-forget) / 1 (primary ack) / "majority" (majority of voters) / integer N
j true requires the write to be journaled to disk before ack
wtimeout give up waiting for w after this many ms (returns success false-ish; doesn't roll back the write)

Defaults

  • Replica sets default to w: "majority" since 5.0.
  • j defaults to true on the journal-enabled storage engine (every modern install).

When to use each

Workload w j Notes
Production transactional "majority" true data loss guarantees
Analytics ingest, can replay 1 or "majority" false speed
Telemetry, OK to lose 0 false fire-and-forget; ~5× throughput
Cross-region critical "majority" + tag-aware concern true survives DC loss

w: 0 returns immediately. If the write fails server-side, your code never knows. Use only for telemetry-like data where loss is acceptable.

wtimeout is not a rollback

If w: "majority", wtimeout: 5000 and only 1 secondary acks within 5 s, the write IS applied on the primary and the secondary it reached, and may catch up to the rest later. The driver reports the timeout but the write persists. Treat as "warning, not rolled back".


Chapter 5 — Read concern

1
2
db.collection('orders').find(filter, { readConcern: { level: 'majority' } })
Level What
local default for primary reads; sees latest writes including those not yet majority-acked
available default for secondary reads (sharded); allows orphan reads
majority only reads data that has been acked by a majority of voters — survives elections without rollback
linearizable strongest; primary-only; slow; guarantees "no stale read", waits for ongoing writes
snapshot inside multi-document transactions

For most apps, default local is correct. Use majority when you can't tolerate the rare case of reading a write that's later rolled back by an election. Use linearizable only when truly needed (compliance, financial settlement) — slow.


Chapter 6 — Read preference

1
2
client.db('shop').collection('orders').find(filter, { readPreference: 'secondaryPreferred' })
Preference What
primary default; reads from primary only
primaryPreferred primary if available, else secondary
secondary secondary only; throws if none available
secondaryPreferred secondary if available, else primary
nearest lowest-latency member (primary or secondary)

When to use secondaries

  • Reporting / analytics that tolerates seconds of staleness.
  • Read scaling for read-heavy workloads.
  • Region-local reads (with tagSets to target a specific DC).

When NOT to use secondaries

  • Read-your-own-writes (RYOW) without causal consistency (next chapter).
  • Anything that needs the freshest data (orders, balances).

Tag sets — target a region

1
2
{ readPreference: 'secondaryPreferred', readPreferenceTags: [{ region: 'apac' }] }

Useful in multi-region deployments where you want reads to stay local.


Chapter 7 — Causal consistency + sessions

Reads on a secondary may lag the primary. If you insert then immediately find on a secondary, you may not see your own write.

Causal consistency ties them together via a session:

1
2
3
4
5
6
7
const session = client.startSession({ causalConsistency: true });
const orders = db.collection('orders');

await orders.insertOne({ _id: 1, ... }, { session });
const doc = await orders.findOne({ _id: 1 }, { session });
// guaranteed to see the insert, even on a secondary

The session carries a clusterTime token; the secondary waits to catch up to it before answering the read.

Sessions are also required for transactions (Post 09).


Chapter 8 — Retryable writes + reads

1
2
mongodb+srv://...?retryWrites=true&retryReads=true

Both default to true. The driver automatically retries an operation once if it suspects a network blip or replica-set election.

Constraints:

  • Only retries idempotent operations (insertOne, updateOne with _id filter, etc.).
  • Doesn't retry updateMany (could partially apply).
  • Server logs duplicate-key retries safely.

Always leave enabled. Together with replica-set primary election (Post 10), they make failovers transparent.


Chapter 9 — Long queries + cursor lifetime

A cursor on the server has a lifetime: by default it's closed after 10 minutes of inactivity (cursorTimeoutMillis server setting). For batch jobs iterating slowly, you must keep the cursor alive or fetch faster.

1
2
3
4
5
6
7
// Node — disable timeout (driver re-asserts cursor on each batch)
const cur = collection.find({}).noCursorTimeout();
for await (const doc of cur) {
await processSlowly(doc);
}
await cur.close(); // always close noCursorTimeout cursors

Without noCursorTimeout, slow consumers eventually see CursorNotFound. With it, always close the cursor in a finally — otherwise it leaks resources on the server.


Chapter 10 — Avoiding "too many connections"

Symptoms: server logs flooded with "connection accepted", connections.current climbing, app sees timeouts.

Causes:

  1. Creating a MongoClient per request (Chapter 1).
  2. Forgetting to close clients in CLIs / scripts.
  3. Serverless functions cold-starting with their own pool each invocation.
  4. Pool too large × too many fleet instances.

Fixes:

  • One MongoClient per process (warm).
  • For serverless, use the provider's connection-pool helper (Atlas Data API, MongoDB Realm/App Services, or a sidecar proxy) — or accept tighter maxPoolSize: 1-5 per instance.
  • Atlas tip: Connection limit dashboard graph; alert at 80 % of the tier limit.
1
2
3
4
5
6
7
8
9
10
// AWS Lambda-style — cached client across invocations
let cached;
export async function handler(event) {
if (!cached) {
cached = new MongoClient(uri, { maxPoolSize: 5 });
await cached.connect();
}
// use cached.db(...)
}

Cold start: opens 5 connections. Warm invocations reuse. The lambda exec env caches cached across invocations on the same container.


Chapter 11 — Server-side concurrency — WiredTiger tickets

mongod uses read + write tickets to limit concurrent storage-engine operations. Default: 128 each. Once exhausted, requests queue.

Symptoms of ticket exhaustion: high wtcache.tickets.read.available near 0 in serverStatus; query latency spikes that can't be explained by individual queries; lock contention in metrics.

Causes: slow queries holding tickets too long, sudden write storms, bad index plans.

Fix order:

  1. Find + fix slow queries (Post 04 profiler).
  2. Add indexes; reduce work per ticket.
  3. Lower app-side concurrency (smaller pool).
  4. Scale up the instance (more cache, more I/O).

Tickets aren't tunable in modern versions — they're sized internally based on the storage engine. Treat them as a signal rather than a knob.


Chapter 12 — Sample production-grade Node driver config

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import { MongoClient } from 'mongodb';

const client = new MongoClient(process.env.MONGO_URI, {
// pool
maxPoolSize: 50,
minPoolSize: 5,
maxIdleTimeMS: 60_000,
waitQueueTimeoutMS: 5_000,

// timeouts
serverSelectionTimeoutMS: 5_000,
connectTimeoutMS: 10_000,

// durability — these are defaults but explicit:
writeConcern: { w: 'majority', j: true, wtimeout: 5_000 },
retryWrites: true,
retryReads: true,

// observability
appName: 'shop-api',
monitorCommands: true, // emits command events for tracing
});

await client.connect();

// graceful shutdown
process.on('SIGTERM', async () => { await client.close(); process.exit(0); });

Tag every client with appName — it surfaces in Atlas + currentOp + slow-query logs so you know which service issued each query.


Chapter 13 — Common pitfalls

Pitfall Fix
MongoClient per request singleton per process
Pool too large size by Little's Law
Connections climbing in serverless cache client across invocations
Read-your-own-write fails on secondary causal consistency sessions
wtimeout interpreted as rollback it isn't — write persists
Slow secondary reads check replica lag (Post 10); maybe use primaryPreferred
Long batch job cursor expires noCursorTimeout() + always close
Idle connections piling up set maxIdleTimeMS
currentOp shows hundreds of waiting ops reduce pool / find slow queries
Atlas alert "connection limit 80%" shrink pool / scale tier / fix client leaks

Practice Exercises

Exercise 1 — Singleton client

Refactor a small script that opens + closes a client per call into a singleton. Run a load test; observe connections.current (via Atlas or db.serverStatus()) flatten.

Exercise 2 — Little's Law

Measure your app's avg latency and QPS. Compute the needed concurrency. Compare to maxPoolSize.

Exercise 3 — Write concern matters

Insert 10k docs with w: 0 vs w: 1 vs w: "majority", j: true. Compare wall-clock time. (Use a local mongod or Atlas M2.)

Exercise 4 — Read preference test

Configure two clients: one primary, one secondary. Insert then immediately read with each. Confirm primary sees the write; secondary may not (without causal consistency).

Exercise 5 — Causal consistency

Repeat Exercise 4 with client.startSession({ causalConsistency: true }). Confirm the secondary read now waits + sees the write.

Exercise 6 — Long cursor

Open a cursor with noCursorTimeout(). Iterate with a setTimeout(... 11 * 60 * 1000) sleep between docs. Confirm it stays open. Also confirm leaving it unclosed is detectable in db.serverStatus().metrics.cursor.


Cheat-sheet

What How
Client lifecycle one per process, long-lived, thread-safe
Pool size maxPoolSize default 100; size by Little's Law
Warm pool minPoolSize
Idle close maxIdleTimeMS
Wait timeout waitQueueTimeoutMS
Topology timeout serverSelectionTimeoutMS (lower for fast-fail)
Write concern { w: 'majority', j: true, wtimeout: 5000 } default
Fire and forget w: 0 (only for loss-tolerant)
Read concern default local; majority for survives-election
Read preference default primary; secondaryPreferred for read scaling + analytics
Region target readPreferenceTags: [{ region: 'apac' }]
Read-your-own-write on secondary causal consistency session
Retry retryWrites=true&retryReads=true (default on)
Long cursor noCursorTimeout() + always close
Serverless cache client across invocations; small maxPoolSize
Tag client appName: 'service-name'
Don't new MongoClient per request

What's next

Post 09 covers multi-document transactions — ACID across documents and collections, sessions, transient errors + retry, commit / abort, and the rule that says "if you reach for a transaction, ask if you can redesign the schema first".


References

Note: this post is in progress. Planned additions: Atlas connection limit graph annotations, PgBouncer-style mongo proxies (mongoproxy, RonDB-Mongo), driver-specific pool quirks per language.