MongoDB Performance: 5 Ways Fast Queries Turn Slow at Scale
The dashboard query ran in 4 milliseconds when the collection held 1 GB. Eighteen months later the collection holds 100 GB, the same query times out, and nothing in the code changed.
That trajectory is the default MongoDB performance story, and it's baked into how the database works. MongoDB is forgiving at small scale: when the whole dataset fits in the WiredTiger cache, even a full collection scan finishes before anyone notices. The flexible document model lets you ship schema decisions you'll regret at 50x the data volume. Nothing breaks on day one — the cost arrives later, compounding, usually on the collection your product depends on most.
The failure modes are predictable, though. Across mid-market production deployments, slow MongoDB clusters trace back to the same five causes over and over. This guide walks through each one: what it is, why teams keep shipping it, one concrete way to detect it today, and the impact range you can expect. Total time to run every check: under an hour.
This is part one of a three-part series. Part two is a hands-on MongoDB performance audit using only native tools; part three covers turning that audit into continuous, automated tuning with an AI agent.
1. Collection scans that were invisible at launch
The problem. A query with no usable index makes MongoDB read every document in the collection — a COLLSCAN in query-plan terms. At 50,000 documents that's a few milliseconds and nobody's problem. At 50 million documents it's a multi-second operation that hammers the cache, evicts hot data other queries needed, and stacks up under concurrency until the whole node degrades.
Why it happens. MongoDB doesn't refuse to run an unindexed query; it just runs it slowly. Queries added after the initial schema design — a new filter in an admin screen, an analytics endpoint, a support tooling lookup — ship without anyone asking whether an index exists, because in staging they were fast.
How to spot it. Run your suspect query through explain with execution stats:
db.orders.find({ customerId: "c-2841", status: "open" })
.explain("executionStats")
Two things to read in the output (explain docs): the winning plan's stage — COLLSCAN means no index was used — and the ratio of totalDocsExamined to nReturned. A healthy indexed query examines roughly as many documents as it returns. If MongoDB examined 4 million documents to return 12, you've found your problem.
Typical impact. The largest single lever in most tuning engagements. Adding the right index to a scanned query routinely cuts examined documents by 100–10,000x and takes p95 latency from seconds back to single-digit milliseconds.
2. Compound indexes built in the wrong order
The problem. Having an index isn't the same as having the right index. A compound index on (status, createdAt, customerId) can be useless for a query filtering on customerId and sorting by createdAt — MongoDB reads index keys in order, so field order decides whether the index prunes the search or merely decorates it. The telltale symptom is a query that uses an index (IXSCAN) yet still examines vastly more keys than it returns, or performs an in-memory sort anyway.
Why it happens. Indexes get created reactively — one per slow query, in whatever field order the developer typed — instead of following a deliberate MongoDB index strategy. The standard guideline is ESR: equality fields first, then sort fields, then range fields (the ESR rule). Most accidental indexes violate it.
How to spot it. The MongoDB profiler catches these in production, where the real query shapes live. Enable it at level 1 to log operations slower than your threshold:
db.setProfilingLevel(1, { slowms: 100 })
Then read the captured operations:
db.system.profile.find({ op: "query" }).sort({ ts: -1 }).limit(10).pretty()
For each entry, compare keysExamined against nreturned, and check for hasSortStage: true — an in-memory sort on an indexed query almost always means the sort field is missing from the index or sits in the wrong position (profiler docs).
Typical impact. Fixing compound-order mistakes on hot queries commonly reduces keys examined by 10–100x. The flip side matters too: every redundant index you added while guessing slows down every write, so consolidation often speeds up inserts by a measurable margin as well.
3. Documents that never stop growing
The problem. The document model makes it natural to embed related data — and dangerously easy to embed data that grows without bound. An events array that gains an entry per user action, a comments array on a popular post, an order document accumulating status-history entries. Each update rewrites the entire document in WiredTiger, multikey indexes on the array balloon, and eventually you hit the 16 MB document limit in production, which fails writes outright.
Why it happens. At design time the array holds three items and embedding looks obviously correct. Nothing in development ever pushes it to three thousand. MongoDB's own schema guidance calls this the unbounded-array anti-pattern (schema anti-patterns) — it's arguably the most common one in the wild.
How to spot it. Rank your largest documents and array sizes with an aggregation (requires MongoDB 4.4+ for $bsonSize):
db.customers.aggregate([
{ $project: { _id: 1, events: { $size: "$events" }, bytes: { $bsonSize: "$$ROOT" } } },
{ $sort: { bytes: -1 } },
{ $limit: 10 }
])
Also compare avgObjSize from db.customers.stats() over time — steady growth in average document size on a collection whose record count is also growing is the signature of embedded accumulation.
Typical impact. Update latency on affected documents grows linearly with document size, and a single collection with runaway arrays can dominate cache usage for the entire node. The fix — bucketing or moving the array to its own collection — is a schema migration, which is exactly why you want to catch this at 200 KB per document, not 14 MB.
4. A working set that no longer fits in RAM
The problem. MongoDB is fast when the data and indexes your queries actually touch — the working set — live in the WiredTiger cache. By default that cache is roughly half of system RAM minus 1 GB. When the working set outgrows it, every query mix triggers disk reads and cache evictions, and latency degrades across the board rather than on any one query. This is the failure mode behind "everything got slow and no single query is to blame."
Why it happens. Data grows continuously; the instance size was chosen once. Nobody notices the crossover point because it isn't an event — it's a gradual rise in read latency that gets blamed on everything else first.
How to spot it. db.serverStatus() exposes the WiredTiger cache counters:
const c = db.serverStatus().wiredTiger.cache
c["maximum bytes configured"]
c["bytes currently in the cache"]
c["pages read into cache"]
c["pages evicted by application threads"]
Cache bytes pinned near the configured maximum is normal on its own. The warning signs are a high, steadily climbing rate of pages read into cache and — worse — application threads doing evictions, which means queries are paying the eviction cost inline (serverStatus docs). Sample these counters twice, a few minutes apart, and look at the deltas.
Typical impact. Crossing the working-set boundary typically moves read p99 from milliseconds to tens or hundreds of milliseconds fleet-wide. The remedies — better indexes to shrink the working set, archiving cold data, or more RAM — differ wildly in cost, which is why diagnosing this correctly beats reflexively upsizing the cluster.
5. Write concern stalls and index builds at the wrong time
The problem. Since MongoDB 5.0, the default write concern is w: "majority" — every acknowledged write waits for a majority of replica-set members. That's the right default, but it means replication lag on a secondary silently becomes write latency on your primary. Index builds are the classic trigger: even the modern hybrid build (4.2+) no longer locks the collection, but it still consumes CPU, disk I/O, and cache on every node — and on busy collections it can run for hours, dragging secondaries behind exactly when a majority write needs them.
Why it happens. Index builds get kicked off during business hours because the fix for problem #1 or #2 felt urgent. Nobody correlates the write-latency spike that follows with the build, because the build is happening "in the background."
How to spot it. Check for in-flight index builds and long-running operations:
db.currentOp({ "command.createIndexes": { $exists: true } })
And check replication health from the primary:
rs.printSecondaryReplicationInfo()
Sustained lag of more than a few seconds on any voting member, combined with elevated write latency, means majority writes are waiting on a struggling secondary (replica set docs).
Typical impact. Intermittent and painful: write p99 spikes of 10–100x during builds or lag events, often misdiagnosed as application bugs. The fix is procedural — build indexes in low-traffic windows, watch lag while they run — which makes this the cheapest problem on this list to eliminate.
Why the fix never stays fixed
Here's the pattern that separates teams that tune once from teams that stay fast: every one of these five problems regenerates.
Each feature ships new query shapes that arrive unindexed. Data distribution drifts until yesterday's optimal compound index misses today's predicates. Documents keep growing; the working set keeps expanding toward the cache ceiling; the next urgent index build lands at 2 PM on a Tuesday. A tuning sprint buys you a fast quarter, not a fast system.
The durable answer is making these checks routine instead of heroic. Part two of this series turns the detection methods above into a full repeatable audit — profiler configuration, explain interpretation, $indexStats for dead indexes — using nothing but tools already in your deployment.
Make the checks continuous
Every check in this guide is a point-in-time snapshot of a moving system. CloudThinker connects to MongoDB read-only and runs them continuously — watching the profiler, index health, document growth, and cache pressure — surfacing findings with evidence the day they appear, and gating any change behind your approval. Try CloudThinker free — 100 premium credits, no card required — or continue with the DIY audit guide.
