MongoDB Course — 08 · Connection Pool, Concurrency, Write/Read Concern
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.
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. jdefaults 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
tagSetsto 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
_idfilter, 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:
- Creating a
MongoClientper request (Chapter 1). - Forgetting to close clients in CLIs / scripts.
- Serverless functions cold-starting with their own pool each invocation.
- Pool too large × too many fleet instances.
Fixes:
- One
MongoClientper 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-5per 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:
- Find + fix slow queries (Post 04 profiler).
- Add indexes; reduce work per ticket.
- Lower app-side concurrency (smaller pool).
- 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
- Connection Pool Overview
- Write Concern
- Read Concern
- Read Preference
- Causal Consistency
- Retryable Writes
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.










