08 · Performance Tuning: WiredTiger, Memory & Connections¶
Most MongoDB performance problems are solved by the work in Levels 2 and 3 — the right indexes, a schema that matches the queries, pipelines that filter early. This lesson covers what's left once those are done: how the storage engine uses memory, what compression costs and saves, how connections behave, and which server and OS settings matter. Every claim is backed by a measurement on this course's lab, with an honest note on how a laptop differs from a production server.
Lab: MongoDB 9.0.2 on a laptop with 8 GB of RAM and 8 cores, PyMongo 4.18.3.
The WiredTiger cache¶
WiredTiger keeps recently used data and index pages in its own cache, uncompressed. Its default size is the larger of 256 MB and 50% of (RAM − 1 GB). Started with no cache option on the 8 GB machine:
(8,192 − 1,024) × 0.5 = 3,584 MB — exactly the formula. The rest of memory isn't wasted: the operating system's file cache holds recently read compressed data files, and every connection, sort, aggregation and index build uses memory outside the cache.
Two consequences:
- Never run two
mongods on one host with default settings. Each claims half the RAM. That's why every lab in this course sets--wiredTigerCacheSizeGB 0.25. - In containers, set the cache explicitly (or make sure the container's memory limit is visible to
mongod). A cache sized from the host's RAM inside a small container gets the process killed.
Working set: what has to fit¶
The working set is the data and indexes your workload touches regularly. The test server has a 256 MB cache and a collection of 1.2 million documents with 400 random characters each:
Random characters don't compress, so the collection is almost twice the cache. After a restart (empty WiredTiger
cache), 20,000 random _id lookups, first restricted to 5% of the documents, then across all of them:
def run(label, hi, n=20000):
r = random.Random(1); p0, _ = cache(); t = time.perf_counter()
for _ in range(n):
coll.find_one({"_id": r.randrange(hi)})
...
hot 5% (60k docs), cold cache 9014 reads/s | pages read into cache 1280 | cache used 29 MB
hot 5% (60k docs), warm 9567 reads/s | pages read into cache 0 | cache used 29 MB
whole collection (1.2M docs) 4644 reads/s | pages read into cache 14524 | cache used 209 MB
whole collection, again 7774 reads/s | pages read into cache 12855 | cache used 218 MB
- The hot 5% (about 25 MB) fit easily: after the first pass it was entirely cached and no pages were read.
- Reading across the whole collection, the cache filled to about 210–220 MB — WiredTiger keeps it around 80% full and evicts to stay there — and kept reading pages in: 12,855 page reads on the second pass, because the working set no longer fits. Each read evicts something that may be needed again soon.
The honest caveat: the gap here (about 7,800 vs 9,600 reads/s once warm) is small because this laptop's operating
system had the whole 497 MB file in its own memory, so a cache miss cost a copy and decompression rather than a
disk read. On a server whose data is many times larger than RAM, a miss is a real disk I/O — tens or hundreds of
microseconds on SSDs, milliseconds on network storage — and the same pattern becomes an order-of-magnitude
slowdown. Watch pages read into cache per second: if it stays high under normal load, the working set doesn't
fit, and you need more memory, smaller documents, better indexes (fewer pages touched), or sharding.
Indexes deserve special attention: an index used by every query must stay in memory to be fast. Compare
totalIndexSize across collections with the cache size; unused indexes (find them with $indexStats) cost cache
and write time for nothing.
Compression: disk versus CPU¶
WiredTiger compresses data blocks on disk with snappy by default; zstd, zlib and none are options per
collection. The same 200,000 order-like documents in each:
p.createCollection("c_" + c, {storageEngine: {wiredTiger: {configString: "block_compressor=" + c}}})
none data 40.0 MB | on disk 41.7 MB | ratio 0.96 | insert 5417 ms
snappy data 40.0 MB | on disk 13.3 MB | ratio 3.00 | insert 5393 ms
zlib data 40.0 MB | on disk 7.2 MB | ratio 5.55 | insert 5404 ms
zstd data 40.0 MB | on disk 8.6 MB | ratio 4.65 | insert 5391 ms
On this repetitive, text-heavy data, snappy cut disk use by two-thirds, and zstd and zlib by over 80% — with zlib slightly ahead at default levels on this sample. The insert times didn't differ because the mongosh script generating documents was the bottleneck, not the server; compression CPU cost is real but small next to everything else in this test. The random-character collection above, by contrast, didn't compress at all (497 MB on disk for 490 MB of data). Measure with your own documents.
Choosing:
- snappy (default): cheap and fast; a fine default.
- zstd: noticeably smaller files for modest extra CPU; good for large, cold or archival collections, and for
network compression between client and server (
compressors=zstdin the connection string). - none: rarely worth it.
- Compression affects disk and the OS file cache, not the WiredTiger cache — pages are decompressed in memory. Smaller files mean more data fits in the OS file cache, which itself improves performance when the working set overflows WiredTiger's cache.
Connections¶
One client per process, not per request¶
Creating a MongoClient per request was twelve times slower and opened about three connections per client
(the driver's monitoring connection, its round-trip-time monitor, and one for the operation). Add TLS and
authentication and each new client costs several round trips plus CPU on both sides. A MongoClient is
thread-safe and maintains a pool; create it once at startup and share it. In serverless functions, create it
outside the handler so warm invocations reuse it.
Pool size is not a throughput dial¶
64 Python threads doing random lookups through one client, with different pool limits:
maxPoolSize=1 8154 reads/s (server connections now: 6)
maxPoolSize=4 9512 reads/s (server connections now: 9)
maxPoolSize=16 8653 reads/s (server connections now: 21)
maxPoolSize=100 8885 reads/s (server connections now: 69)
Throughput was flat: the bottleneck was the Python client (one interpreter, the GIL), not connections, so more connections just meant more idle sockets. In other languages or with more client processes the curve rises further before flattening, but the shape is universal: beyond the server's ability to execute operations in parallel (bounded by cores and storage), more connections add memory and contention, not speed.
Budget connections across the fleet: 40 application instances × maxPoolSize 100 is 4,000 potential connections
per mongod, each with its own memory. Size pools from measured concurrency, set maxIdleTimeMS so bursts don't
leave thousands of idle sockets, and set waitQueueTimeoutMS / timeoutMS so a saturated pool fails fast rather
than queueing forever.
Server and OS settings worth knowing¶
These follow MongoDB's production notes; the details change between versions, so check the notes for the version you run. None were benchmarked here — a laptop can't meaningfully test them.
- File system: XFS is recommended for WiredTiger on Linux.
ulimit: raise open files and processes (MongoDB suggests 64,000); each connection and each data/index file uses descriptors.- Swappiness: keep it low (often 1) so the kernel doesn't swap out the cache.
- Transparent huge pages: the recommendation reversed in MongoDB 8.0 — older versions said disable THP; 8.0 and later, with an upgraded TCMalloc, recommend enabling it. A runbook copied from an older guide gets this backwards.
- NUMA: on multi-socket machines, use interleaved memory allocation (
numactl --interleave=all). - Read-ahead: small values (around 8–32 sectors) on data volumes; large read-ahead wastes the file cache.
- Clock sync: NTP/chrony on every member; replication and logs depend on sane clocks.
- Disks: SSDs, and separate volumes for data and logs if your platform's I/O is shared.
mongod checks several of these at startup and prints warnings in the log (and in mongosh's startup banner).
Read them rather than suppressing them.
A tuning method¶
- Find the expensive operations — slow log and profiler grouped by query shape (Lesson 07).
- Fix the query or index first. A
COLLSCANturned into anIXSCANbeats any server setting. - Check the working set: cache usage, dirty percentage, pages read per second.
- Check connections and queues: tickets in use, queued operations, connection counts and churn.
- Check replication: lag, and whether
majoritywrites are waiting on a slow secondary. - Change one thing, measure again, on production-like data and load.
How It Actually Works¶
WiredTiger stores each collection and index as a B-tree in its own file. Pages on disk are compressed blocks; when a page is needed, it's read (from the OS file cache or disk), decompressed and expanded into an in-memory format in the cache. Updates create new versions of records in memory (that's how readers get consistent snapshots without locks), making pages dirty. Checkpoints (every 60 seconds by default) write dirty pages back as new compressed blocks, and the journal records changes between checkpoints for crash recovery.
Background eviction threads keep the cache below its targets: they start evicting clean pages when usage
passes about 80% (eviction_target) and work harder on dirty content past its own threshold. If the cache reaches
its hard triggers (around 95% used, or too much dirty data), application threads are made to do eviction
before their operations proceed — the moment latency suddenly spikes in an overloaded system. That's why the test
above settled at about 210–220 MB of a 256 MB cache: eviction holding it near the target while new pages arrived.
Common mistakes¶
- Tuning server knobs before fixing queries and indexes.
- Default cache settings for several
mongods on one host, or inside a memory-limited container. - Ignoring index size relative to memory; keeping unused indexes.
- A new
MongoClientper request or per function invocation. - Huge pool sizes on many app instances, multiplying into tens of thousands of server connections.
- Applying THP and other OS advice from a guide written for a different MongoDB version.
- Benchmarking on a laptop and extrapolating to production storage. Use laptops to compare alternatives, as this lesson does, and production-like hardware to size.
Exercise¶
- Load a collection 3× larger than a 256 MB cache. Measure random-read throughput and pages read per second for hot ranges of 1%, 10%, 25% and 50%. Plot where performance falls off.
- Repeat the compression comparison with your own application's documents (or a public data set). Add a read test: time a full collection scan for each compressor after a restart.
- Write a "client per request" and a "shared client" version of a small web endpoint (Flask or FastAPI) and load
test both. Count
totalCreatedconnections on the server for each. - Run the pool-size test from Node.js with the async driver and 200 concurrent promises. Does the curve differ from Python's?
- Read every startup warning your
mongodprints, look up each in the production notes for your version, and decide which apply to your deployment.