You've indexed (Post 03), tuned queries (Post 04), sized the pool (Post 08), and put metrics in place (Post 14). When everything looks healthy but performance still isn't, the answer is one layer deeper — WiredTiger (the storage engine), the working-set vs cache relationship, document compression, MVCC + locks, and the patterns that produce hot keys + large-document problems. This post covers the in-depth performance knobs that decide whether a well-tuned cluster handles 10k QPS or 100k QPS.

Series map: 00 Foundations → … → 14 Backup & Ops → 15 Performance Deep (this post) → 16 Atlas → 17 Drivers & Case Studies.


Learning Outcome

  • Explain WiredTiger cache + how the working set decides RAM sizing.
  • Configure + compare snappy / zstd / zlib compression.
  • Understand document-level locking + MVCC + ticket exhaustion.
  • Recognise + fix hot-key / large-document / array-growth patterns.
  • Use GridFS for files bigger than 16 MB.
  • Diagnose with serverStatus, db.stats(), FTDC.

Chapter 1 — WiredTiger cache

WiredTiger is MongoDB's default storage engine (since 3.2). It keeps recently-accessed B-tree pages + index entries in an in-memory cache:

  • Default size: max(50% of RAM - 1 GB, 256 MB).
  • Tunable: storage.wiredTiger.engineConfig.cacheSizeGB.
1
2
3
4
5
storage:
wiredTiger:
engineConfig:
cacheSizeGB: 32

Cache holds:

  • Indexes pages (priority).
  • Recently-read document pages.
  • Active write buffers.

Cache hit rate matters more than absolute RAM. A query touching an index page already in cache is microseconds. Touching one from disk is milliseconds (SSD) or tens of ms (slow disk).

Inspect:

1
2
db.serverStatus().wiredTiger.cache

Key fields:

  • bytes currently in the cache
  • tracked dirty bytes in the cache (modified, not yet checkpointed)
  • pages evicted by application threads — bad sign; app waited for eviction
  • unmodified pages evicted — fine
  • modified pages evicted — fine; happens periodically

If pages evicted by application threads is climbing fast, the cache is undersized for your working set.


Chapter 2 — Working set sizing — the most important number

The working set is "data + indexes you actually touch frequently". For read-heavy workloads, working set should fit in the WiredTiger cache.

Rough estimate:

1
2
working_set ≈ (active indexes' size) + (5-10% of frequently-accessed docs' size)

You can measure:

  • db.collection.stats() → size, totalIndexSize.
  • $indexStats → which indexes are actually used.

If working set > cache:

  • Cache thrashes — every query causes evictions.
  • Queries that should be < 1 ms become 10-50 ms.
  • Latency p99 spikes.

Fix priority:

  1. Drop unused indexes.
  2. Reduce document size (don't store unused fields; compress text outside Mongo).
  3. Archive old data (Atlas Online Archive or manual move).
  4. Scale up to a bigger RAM tier.
  5. Last resort: shard (Post 11) — distributes working set across multiple machines.

Working set versus the WiredTiger cache: when it fits reads stay fast, when it is too big the cache thrashes, and the fixes in order


Chapter 3 — Compression

WiredTiger compresses blocks before writing to disk. Three options, configurable per collection or globally:

Codec CPU Ratio When
snappy (default) low ~3× balanced; default for collections
zstd (5.0+) medium ~4× better ratio, modest CPU bump; modern default for new collections
zlib high ~5× archival, cold data, CPU plentiful
none none 1× rarely useful

Per-collection at creation:

1
2
3
4
db.createCollection("orders", {
storageEngine: { wiredTiger: { configString: "block_compressor=zstd" } }
})

For most new collections in 2026, zstd beats snappy: similar speed, better compression → smaller cache footprint → higher hit rate.

Indexes are compressed by prefix compression separately (always on).

Trick: if your working set barely fits in RAM, switching from snappy → zstd often gives you 25 % more effective cache for free. Test under load before flipping in production.


Chapter 4 — Document-level locking + MVCC

WiredTiger uses multi-version concurrency control (MVCC):

  • Readers see a consistent snapshot at their start time.
  • Writers create new versions; old versions linger until no reader needs them.
  • Locking is at the document level — different documents in the same collection don't block each other.

Practical implications:

  • High concurrency on different documents = fine.
  • High concurrency on the SAME document = serialised (hot-key problem, next).
  • Long readers force old versions to linger → memory pressure.

No collection-level lock during ordinary CRUD. DDL (createIndex, drop, collMod) still locks the collection briefly.


Chapter 5 — Hot keys + write hotspots

Symptom: a small subset of documents (one user, one product, one counter) receives most writes. WiredTiger serialises writes per document. The cluster has spare capacity but that one document caps throughput.

Examples:

  • A "global stats" doc incremented on every event.
  • A celebrity user's profile updated by every fan's like.
  • An auto-increment sequence counter.

Fixes:

  1. Sharded counter pattern — split one counter into N shards, increment a random one, sum on read:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// write
const shardId = Math.floor(Math.random() * 16);
db.counters.updateOne(
{ _id: `pageviews:${shardId}` },
{ $inc: { count: 1 } },
{ upsert: true }
);

// read
db.counters.aggregate([
{ $match: { _id: { $regex: "^pageviews:" } } },
{ $group: { _id: null, total: { $sum: "$count" } } }
])
  1. Move to per-event docs — instead of incrementing a counter, insert one doc per event; aggregate on read. Trade write hotspot for storage + aggregation cost. Use TTL to bound storage.

  2. Async aggregation — log raw events; a background job rolls them up periodically (change stream + $merge).


Chapter 6 — Large-document anti-pattern

Documents have a 16 MB BSON ceiling. Approaching it brings problems even earlier:

  • Every update rewrites (copy-on-write) the whole document — slow.
  • Indexes on fields inside huge docs are inefficient.
  • Network + WiredTiger cost grows.

Practical limit: keep docs under ~1 MB for hot writes.

Causes of bloat:

  • Unbounded arrays — comments, followers, history.
  • Embedded binary data — images, PDFs.
  • Schema drift — old fields accumulating.

Fixes:

  1. Cap arrays: $push: { items: { $each: [x], $slice: -100 } }.
  2. Move to child collection.
  3. GridFS for large binary (Chapter 7).
  4. Compress before storing (gzip text), decompress on read.

For files that need to live in MongoDB, GridFS is the safer answer.


Chapter 7 — GridFS

GridFS splits large files into chunks (default 255 KB) stored across two collections (<bucket>.files + <bucket>.chunks).

1
2
3
4
5
6
7
8
9
10
11
12
import { GridFSBucket } from 'mongodb';

const bucket = new GridFSBucket(db, { bucketName: 'uploads' });

fs.createReadStream('big.mp4').pipe(
bucket.openUploadStream('big.mp4', { metadata: { uploader: 'ada' } })
);

bucket.openDownloadStreamByName('big.mp4').pipe(
fs.createWriteStream('downloaded.mp4')
);

Use GridFS when:

  • Files > 16 MB.
  • You want metadata + access control alongside the file in one system.
  • Replication + sharding of the files for free.

Better alternatives (in 2026):

  • S3 (or other object storage) + store URL + metadata in MongoDB. Cheaper, faster, scales further.
  • GridFS is convenient for small projects; object storage wins at scale.

Chapter 8 — Concurrency tickets (recap from Post 08)

WiredTiger limits concurrent storage-engine operations via read + write tickets (default 128 each).

1
2
3
db.serverStatus().wiredTiger.concurrentTransactions
// { write: { out: 12, available: 116, totalTickets: 128 }, read: { ... } }

available near 0 = exhaustion → operations queue → latency spikes.

Causes:

  • Slow queries holding tickets too long (most common).
  • Sudden write storm exceeding capacity.
  • Bad index plans.

Fix order:

  1. Find + fix slow queries (Post 04).
  2. Reduce app-side concurrency (smaller pool).
  3. Scale up.

Tickets aren't directly tunable in modern versions — they're sized by the storage engine.


Chapter 9 — Long readers + MVCC pressure

A long-running read holds an MVCC snapshot. Old document versions can't be reclaimed until the reader is done. Long readers + heavy writers = memory pressure (cache fills with version chains).

Symptoms: transactions.transaction store growing, cache evictions climbing during long-running aggregations.

Mitigations:

  • Set maxTimeMS on every user-facing query (Post 02).
  • Run heavy aggregations on a secondary (with secondaryPreferred) to isolate.
  • Break long aggregations into chunked stages writing to a $merge target.

Chapter 10 — Index-pages-in-RAM math

A useful sanity check:

1
2
3
hot_index_size = sum(index sizes used in hot queries)
ratio = hot_index_size / wiredtiger_cache

If ratio > 1, you can't keep indexes in RAM — queries that should be index-only will spill to disk.

Get sizes:

1
2
3
db.orders.totalIndexSize()
db.orders.stats().indexSizes

Add up the ones from $indexStats (Post 04) with non-trivial ops. That's your hot index footprint.


Chapter 11 — FTDC + diagnostic data

MongoDB writes Full-Time Diagnostic Data Capture files to dbpath/diagnostic.data/. Continuous metrics, queryable after the fact with tools like mongo-profiler + Atlas's built-in analyzer.

For self-hosted root-cause analysis:

  • Capture FTDC during an incident; share with MongoDB support or analyse via OSS tools.
  • Combine with mongod logs (slow ops, election events, ticket waits).

Atlas: FTDC is collected automatically; the Atlas team can access it for support cases.


Chapter 12 — Sample production tuning checklist

For a new self-hosted production cluster:

  • [ ] WiredTiger cache sized to fit working set + headroom.
  • [ ] zstd compression for cold-ish collections.
  • [ ] Indexes audited; unused dropped.
  • [ ] Hot-counter pattern uses sharded counters.
  • [ ] No collection-level arrays growing unbounded.
  • [ ] Large binaries in GridFS or S3, not embedded.
  • [ ] maxTimeMS on user-facing queries.
  • [ ] Profiler at slowms: 100.
  • [ ] FTDC retained for 30 days.
  • [ ] Prometheus + Grafana dashboard alerting on the Post 14 metrics.
  • [ ] Restore drill scheduled monthly.

Chapter 13 — Common pitfalls

Pitfall Symptom Fix
Working set > RAM latency spike under load; cache evictions high scale RAM; drop indexes; archive old data
Hot key one doc gets all writes; throughput capped sharded counter pattern
Unbounded array doc grows toward 16 MB cap with $slice; move to child collection
Files in MongoDB hot writes slow + GridFS overhead S3
Default snappy when zstd would fit better larger on-disk footprint switch (test first)
Long-running aggregation on primary cache pressure run on secondary; or $merge chunked
Ticket exhaustion latency spike + tickets near 0 fix slow queries first; reduce client concurrency
Index not in RAM predictable slow queries index pruning; bigger tier
Big update copy-on-write slow updates on huge docs shrink the doc; partial updates only of what changed

Practice Exercises

Exercise 1 — Cache hit rate

Pull db.serverStatus().wiredTiger.cache before + after a synthetic workload. Compute the application-thread eviction rate. Increase load until the rate becomes alarming.

Exercise 2 — zstd vs snappy

Create two collections with identical schema, one snappy + one zstd. Insert 1M synthetic docs into each. Compare on-disk size (db.collection.stats()).

Exercise 3 — Hot counter remediation

Build a global page-view counter naively (one doc incremented). Stress it with 100 concurrent writers. Note the throughput plateau. Refactor to sharded counter pattern (Chapter 5). Repeat — should scale linearly.

Exercise 4 — GridFS round-trip

Upload a 200 MB file via GridFS. Download to a new path. Confirm checksum matches.

Exercise 5 — Capacity scenario

Given: 200 GB index across all hot collections; 64 GB RAM tier (WT cache ≈ 30 GB). Compute whether the working set fits. Recommend an action.


Cheat-sheet

What How
WT cache default 50 % RAM − 1 GB
Inspect db.serverStatus().wiredTiger.cache
App-thread evictions ↑ working set > cache
Working set sizing hot indexes + ~5-10 % hot docs
Compression options snappy / zstd (recommended) / zlib / none
Per-collection compression storageEngine.wiredTiger.configString
MVCC per-doc; readers see consistent snapshot
Hot key fix sharded-counter pattern
Array cap $push: { $each: [x], $slice: -100 }
Large file GridFS (or better: S3)
Tickets 128 default; not directly tunable
Long reader pressure maxTimeMS; run on secondary
Index size check db.coll.stats().indexSizes
FTDC dbpath/diagnostic.data/
Production checklist Chapter 12

What's next

Post 16 covers Atlas-only features — Atlas Search (Lucene-based full-text + facets + autocomplete), Atlas Vector Search (k-NN for embeddings — the RAG building block), Atlas Triggers (scheduled + database + auth + HTTP), Atlas Stream Processing, and Data Federation / Online Archive.


References

Note: this post is in progress. Planned additions: real workload benchmarks (snappy vs zstd); kernel vm.swappiness=1; transparent huge pages disable script; NUMA-aware sizing.