MongoDBPerformance Tuning Deep Dive

Performance Tuning

Most MongoDB performance problems trace back to one of a handful of root causes: the working set doesn't fit in RAM, a hot query has no supporting index, or the connection pool is misconfigured. This page walks through the diagnostic tools and the fixes they point to.

Working Set in RAM

Your "working set" is the data and indexes actively touched by your queries. As long as it fits in the WiredTiger cache (roughly 50% of available RAM minus 1GB, by default), reads are served from memory. Once it doesn't fit, MongoDB has to page data in from disk, and latency climbs.

JS
db.serverStatus().wiredTiger.cache["bytes currently in the cache"]
db.serverStatus().wiredTiger.cache["maximum bytes configured"]
db.serverStatus().wiredTiger.cache["tracked dirty bytes in the cache"]
Tip
Sustained cache utilization near 100% combined with a rising eviction rate is the clearest signal your working set has outgrown RAM — the fix is usually more RAM, a better index (so less data needs to be scanned per query), smaller documents, or sharding to spread the working set across nodes.
WiredTiger Cache Tuning

Bash
# mongod.conf — explicitly size the cache (leave headroom for the OS
# filesystem cache and other processes on the host)
storage:
  wiredTiger:
    engineConfig:
      cacheSizeGB: 8
The Slow Query Log / Profiler

JS
// Level 1: log only operations slower than slowms (default 100ms)
db.setProfilingLevel(1, { slowms: 100 })

// Level 2: log EVERY operation — useful briefly for deep debugging,
// too much overhead to leave on in production
db.setProfilingLevel(2)

// Level 0: off (default)
db.setProfilingLevel(0)

db.system.profile.find().sort({ ts: -1 }).limit(20).pretty()

Profiling Level

Behavior

0

Profiler off — no logging

1

Logs operations slower than slowms

2

Logs every operation regardless of duration

Warning
Leave level 2 profiling running only briefly — logging every operation adds real overhead and can quickly fill system.profile (a capped collection) with noise, pushing out the slow-query entries you actually care about.
currentOp — What's Running Right Now

JS
db.currentOp({ "secs_running": { $gte: 5 } })   // ops running 5+ seconds

// Kill a specific runaway operation once you've identified it
db.killOp(opid)
Index Audit

Unused indexes still cost write overhead (every insert/update maintains every index) and disk space, without ever paying off in faster reads. $indexStats shows real per-index usage counts since the server last restarted.

JS
db.orders.aggregate([{ $indexStats: {} }])
[
  { name: "_id_", accesses: { ops: 452301 } },
  { name: "customerId_1_status_1", accesses: { ops: 98211 } },
  { name: "legacyField_1", accesses: { ops: 0 } }
]

legacyField_1 above has zero accesses — a strong candidate to drop, after confirming it isn't a safety net for an infrequent but important query.

JS
db.orders.dropIndex("legacyField_1")
Explaining a Query

JS
db.orders.find({ customerId: id, status: "shipped" })
  .sort({ createdAt: -1 })
  .explain("executionStats")
  • totalDocsExamined much larger than nReturned → missing or wrong index.

  • stage: "COLLSCAN" in the winning plan → no usable index at all for this query shape.

  • totalDocsExamined: 0 with results returned → a fully covered query (index alone satisfied it).

  • executionTimeMillis climbing over time on the same query shape → working set or data volume outgrowing current indexes.

Schema Fixes for Hot Queries
  • Add a compound index following the ESR rule (Equality, Sort, Range) for the query's exact shape.

  • Project only the fields the caller needs — smaller documents over the wire, less memory pressure.

  • Denormalize/duplicate a couple of frequently-read fields (extended reference pattern) to avoid a $lookup on the hottest path.

  • Precompute expensive aggregates (computed pattern) instead of recalculating them on every read.

  • Cap or bucket unbounded arrays so a single document read doesn't pull megabytes of data you don't need.

Connection Pool Sizing

JS
new MongoClient(uri, {
  maxPoolSize: 50,     // upper bound on concurrent connections per client
  minPoolSize: 10,     // kept warm even when idle, avoids cold-connect latency spikes
  maxIdleTimeMS: 60000
})
Note
A pool that's too small causes requests to queue waiting for a free connection; a pool that's too large just moves the bottleneck to the server's own connection limit, and can starve other services sharing the same cluster. Size it against actual measured concurrency, not a guess.
Hardware / Atlas Tier Signals

Signal

Suggests

Sustained high WiredTiger cache eviction rate

Need more RAM or a smaller working set

High disk IOPS/latency, cache otherwise healthy

Storage tier undersized — faster disk or Atlas tier upgrade

CPU pegged with routine, well-indexed queries

Need more vCPU — scale up, or shard to spread load

Connections near the limit under normal traffic

Application-side connection leak or too many separate services sharing the cluster

Replication lag growing under write load

Secondaries undersized relative to primary write volume

Summary
  • Check whether the working set fits in the WiredTiger cache first — many performance problems are really a memory problem.

  • Use the profiler and currentOp to find slow/long-running operations in real time.

  • Use $indexStats to find and remove indexes that cost write overhead without ever being read from.

  • Read explain() output for hot queries and fix the index (ESR rule) before reaching for more hardware.

  • Size the connection pool from measured concurrency, and watch cache eviction, disk IOPS, CPU, and replication lag as your leading scaling signals.