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 again and again. 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.
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.
01Reads
COLLSCAN
A query with no usable index reads every document. Fine at 50K, painful at 50M.
02Indexes
Wrong order
An index exists but its field order fights the query, so it scans wide or sorts in memory.
03Schema
Unbounded arrays
Embedded arrays that grow forever, until updates crawl or a write hits 16 MB.
04Memory
Working set > cache
Hot data outgrows WiredTiger cache and every query starts paying for disk.
05Writes
Majority stalls
Index builds and lagging secondaries turn into write latency on the primary.
Why the usual response misfires
When a MongoDB cluster gets slow, teams reach for one of two moves, and both are often wrong in a structural way.
Upsizing the instance. More RAM helps when the working set has outgrown the cache (pattern 4). It does nothing for a query that scans 40 million documents to return 12 (pattern 1). It just scans them a bit faster, and the bill goes up for good.
Adding an index per slow query. Each new index helps one query shape and taxes every write on the collection. Built in whatever field order the developer typed, half of them don't fully serve the query they were made for (pattern 2), and some get built at 2 p.m. on a busy primary (pattern 5).
Both moves treat the symptom ("this is slow") instead of reading what the database is telling you. MongoDB is unusually good at telling you: explain, the profiler, $indexStats and serverStatus together explain almost every slow cluster. The table below maps the symptom to the pattern and the first check.
| Symptom | Most likely pattern | First check |
|---|---|---|
| One query slow, others fine | COLLSCAN or wrong index order | explain("executionStats") on that query |
| Query uses an index but is still slow | Wrong compound-index order | keysExamined vs nreturned, look for a SORT stage |
| Updates to some documents get slower over time | Unbounded array growth | $bsonSize ranking on the collection |
| Everything slow, no single query to blame | Working set outgrew cache | WiredTiger cache counters in serverStatus |
| Writes spike, reads fine | Majority write concern waiting on a secondary | rs.printSecondaryReplicationInfo() and in-flight index builds |
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 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');
Read two things 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.
You can also find collection scans already happening in production without guessing which query to explain. Slow operations land in the server log with a planSummary field, and $currentOp shows the ones running right now:
db.getSiblingDB('admin').aggregate([
{ $currentOp: { allUsers: true } },
{ $match: { planSummary: 'COLLSCAN' } },
{ $project: { ns: 1, secs_running: 1, command: 1 } },
]);
Typical impact. The largest single lever in most tuning work. Adding the right index to a scanned query routinely cuts examined documents by 100 to 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 far 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 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 to 100x. The flip side matters too: every redundant index you added while guessing slows down every write, so consolidation often speeds up inserts measurably 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 whole 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), and it's arguably the most common one in the wild.
How to spot it. Rank your largest documents and array sizes with an aggregation (needs MongoDB 4.4+ for $bsonSize). On a large collection, put a $sample stage first so the check doesn't become its own collection scan:
db.customers.aggregate([
{ $sample: { size: 100000 } },
{
$project: {
_id: 1,
events: { $size: { $ifNull: ['$events', []] } },
bytes: { $bsonSize: '$$ROOT' },
},
},
{ $sort: { bytes: -1 } },
{ $limit: 10 },
]);
Also track 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 with document size, and a single collection with runaway arrays can dominate cache usage for the whole node. The fix (bucketing, or moving the array to its own collection) is a schema migration, which is 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 the larger of 50% of (RAM minus 1 GB) or 256 MB, so a 16 GB node gets about 7.5 GB. When the working set outgrows it, the 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 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 near the configured maximum is normal on its own. The warning signs are a high, 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 compare 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" for most replica set configurations: every acknowledged write waits for a majority of members. That's the right default, but it means replication lag on a secondary becomes write latency on your primary. Index builds are the classic trigger. Since 4.2, builds no longer hold an exclusive lock for their whole duration, but they still consume CPU, disk I/O and cache on every data-bearing member, and on busy collections they 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 pattern 1 or 2 felt urgent. Nobody connects 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:
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 troubleshooting).
Typical impact. Intermittent and painful: write p99 spikes of 10 to 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 the list to eliminate.
Why the fix never stays fixed
The pattern that separates teams that tune once from teams that stay fast: every one of these five problems regenerates.
01Tune
Sprint
Indexes added, a schema fixed, the cluster fast again.
02Ship
New shapes
Features add filters and sorts that no index was designed for.
03Grow
Data drifts
Documents grow, distributions shift, the working set creeps toward the cache.
04Decay
Slow again
Last quarter's right index is this quarter's wrong one.
Each feature ships new query shapes that arrive unindexed. Data distribution drifts until yesterday's best 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 p.m. on a Tuesday.
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, and surfaces findings with evidence the day they appear. Part three shows how. CloudThinker acts within Auto Mode, so you decide what runs on its own and what waits for approval.
Related reading
- Part two: the profiler, explain, and $indexStats
- Part three: automating MongoDB performance tuning
- PostgreSQL performance tuning: the 6 metrics that matter
- MySQL slow queries: 5 root causes
Try CloudThinker free with 100 premium credits and no card required, or continue with the DIY audit guide.
