Sale: Use codesave50for 50% off
Back to Blog

How to Fix Slow MongoDB Queries

JayJay

MongoDB queries get slow in two distinct ways, and the difference matters. One is gradual: an index that was great when the collection was a million documents stops fitting in the WiredTiger cache when it grows to a hundred million, and every query starts paying disk-read cost. The other is sudden: someone deploys a new query shape that the planner can't satisfy with any existing index, and a COLLSCAN shows up in the slow log.

The first failure mode requires architectural thinking. The second is a five-minute fix. Most posts about slow MongoDB queries jump straight to "add an index" without distinguishing between them. This one walks through both: the diagnostic stack that surfaces each, how to read explain() output well enough to know which mode you're in, the index strategy that pays off, the schema decisions that make or break things at scale, and the configuration knobs that matter.

Why MongoDB queries get slow

Three things drive almost every slow-query report:

  1. The working set doesn't fit in cache. MongoDB's hot data lives in WiredTiger's cache. When indexes and frequently-accessed documents exceed the cache size, every miss is a disk read. Symptom: high executionTimeMillis even on queries that show IXSCAN.

  2. The planner is missing an index. The query examines orders of magnitude more documents than it returns. Symptom: a COLLSCAN in explain(), or IXSCAN with totalDocsExamined much higher than nReturned.

  3. The schema or aggregation shape is wrong. Documents are too big, arrays are unbounded, $lookup joins a 100M-doc collection without an index on the foreign field, or the pipeline does work in the wrong order.

The first kind of slowness costs you a bigger instance or a sharded cluster. The second costs you 30 seconds and a createIndex. The third costs you a refactor. Knowing which one you're facing is the most important diagnostic step.

Step 1: Stand up a diagnostic stack

You can't fix what you can't see. MongoDB has good built-in instrumentation, but you have to turn most of it on.

The database profiler

The profiler writes slow operations to the capped system.profile collection in the database where they ran. It's the closest equivalent of the Postgres slow log.

Check the current state:

JS
db.getProfilingStatus()
// { was: 0, slowms: 100, sampleRate: 1, filter: undefined }

The levels:

  • 0 is disabled.
  • 1 captures operations above slowms and sampled at sampleRate.
  • 2 captures every operation. Useful in development; expensive in production.

Turn level 1 on with a sample rate, so you don't drown:

JS
db.setProfilingLevel(1, { slowms: 100, sampleRate: 1.0 })

For a busy collection, drop sampleRate to 0.1 or lower. Postgres added log sampling in 13; MongoDB has had it for years and many teams still don't use it.

Query the profile collection for the worst offenders:

JS
db.system.profile.find({}, { ts: 1, millis: 1, op: 1, ns: 1, planSummary: 1, command: 1 })
  .sort({ millis: -1 })
  .limit(10)
  .pretty()

planSummary is gold: it tells you in one line whether the operation used an index. COLLSCAN means a full collection scan. IXSCAN { customerId: 1 } means it used that index.

system.profile is capped at 1MB by default. Resize it if you want a longer history:

JS
db.setProfilingLevel(0)
db.system.profile.drop()
db.createCollection("system.profile", { capped: true, size: 100 * 1024 * 1024 })
db.setProfilingLevel(1, { slowms: 100 })

Slow op logging in mongod.conf

For a persistent record outside the capped system.profile, set the slow op threshold in the server config:

YAML
operationProfiling:
  slowOpThresholdMs: 100
  slowOpSampleRate: 1.0
  mode: slowOp

Slow operations land in the server log with the operation, the duration, the plan, and the namespace. The log persists across restarts and survives cluster failover, which the profile collection does not.

mongostat and mongotop

For real-time view of what the server is doing:

BASH
mongostat --rowcount 0 --uri "mongodb://..."
mongotop --rowcount 0 --uri "mongodb://..."

mongostat shows operations per second, queue length, cache stats, and replication lag. mongotop shows read/write time per collection. Watch the dirty column in mongostat: a sustained high dirty cache percentage means WiredTiger is flushing constantly, which is a sign the cache is undersized for the write workload.

Atlas Performance Advisor and Query Profiler

If you're on Atlas, two features are worth using even on the free tier:

  • Query Profiler surfaces the slow queries with the plan and timing, similar to system.profile but with a UI and a longer history.
  • Performance Advisor suggests indexes based on observed query patterns. Treat the suggestions as starting points, not final answers. It doesn't know your write patterns or partial-index opportunities.

The Atlas Search and Atlas Vector Search admin UI also exposes slow-query views for those workloads specifically.

mtools for log analysis

For self-hosted MongoDB, the mtools Python package is the equivalent of pgbadger. It parses MongoDB log files and produces summaries:

BASH
mloginfo mongod.log --queries
mlogfilter mongod.log --slow 1000 --operation query
mlogvis mongod.log    # interactive HTML visualization

mloginfo --queries is the killer command. It bucket-summarizes by query pattern, shows mean and total time, and highlights which collections are hot. Run it after any incident.

Compass for plan visualization

MongoDB Compass is the official GUI client. The two features worth opening it for:

  1. Explain plan visualization. The text output of explain() is hard to read for nested pipelines. Compass renders it as a tree with timings.
  2. Schema analysis. Compass samples documents and shows field cardinality and distribution. Great for spotting "this field that we always filter on is sparsely populated."

For day-to-day query work, prefer mongosh for muscle memory. For diagnosing a slow query, Compass is faster.

Step 2: Read explain() like a power user

The bare form of explain() only shows the planner's choice. Use executionStats to see what happened during execution:

JS
db.orders.find({
  customerId: 12345,
  status: "pending"
}).explain("executionStats")

The two sections that matter:

JSON
{
  "queryPlanner": {
    "winningPlan": { "stage": "COLLSCAN", "filter": {...} },
    "rejectedPlans": []
  },
  "executionStats": {
    "executionSuccess": true,
    "nReturned": 23,
    "executionTimeMillis": 2834,
    "totalKeysExamined": 0,
    "totalDocsExamined": 500000,
    "executionStages": {...}
  }
}

This says: scanned 500,000 documents, returned 23, took 2.8 seconds. That's the problem.

The ratio that tells the truth

totalDocsExamined divided by nReturned is the single most useful number. If it's close to 1, the index is doing its job. If it's in the hundreds or thousands, the index is wrong or missing. If nReturned is 23 and totalDocsExamined is 500,000, you have a missing index.

totalKeysExamined versus nReturned tells you about index quality even when an index is being used. If both are 23, the index covers the query well. If totalKeysExamined is 5,000 but nReturned is 23, the index narrows the search but doesn't satisfy the filter alone, so MongoDB is reading documents to apply additional filters.

Stage names worth memorizing

StageMeaningAction needed?
IDHACKLookup by _id, the fastest pathNo
COUNT_SCANPure count from an indexNo
IXSCANIndex scanNo
FETCH after IXSCANRead the document after using the indexUsually fine
PROJECTION_COVEREDAnswered from the index aloneGreat, you have a covered query
SORT_MERGEMerging sorted index outputsGood
COLLSCANFull collection scanYes, on a large collection
SORTIn-memory sortAdd an index that produces the order

A SORT stage in production is a smell. It means the query did the work, then sorted the result in memory. For more than a small result set, that's wasted work and risks hitting the sort memory limit.

The 100MB sort cliff

MongoDB caps in-memory sorts at 100MB per stage. A bare find().sort() over that limit fails with:

Sort exceeded memory limit of 33554432 bytes

The aggregation framework can spill to disk with allowDiskUse: true, but the per-stage memory limit is still 100MB without it. Sort spilling is slow and adds I/O pressure. The right fix is almost always an index that produces the order.

Note: allowDiskUse defaults to true in MongoDB 6.0+ for aggregations specifically. The find().sort() path still requires an index for large sorts; there's no allowDiskUse equivalent there.

winningPlan vs rejectedPlans

The query planner picks a winning plan by running candidates against the first few documents. If the wrong plan wins (this happens), allPlansExecution shows you the alternatives:

JS
db.orders.find({...}).explain("allPlansExecution")

The rejectedPlans array shows what else was considered. A plan you'd expect to win that didn't is often a sign of a stale plan cache. You can clear it:

JS
db.orders.getPlanCache().clear()

Plan caches reset on index changes, restart, or after a TTL. Forcing a clear is rarely necessary, but useful as a diagnostic.

Aggregation explain

For pipelines, the structure is different. Each stage has its own execution stats:

JS
db.orders.aggregate([
  { $match: { status: "pending" } },
  { $group: { _id: "$customerId", total: { $sum: "$total" } } },
  { $sort: { total: -1 } },
  { $limit: 100 }
]).explain("executionStats")

The output is an array of stages, each with executionTimeMillisEstimate and a description. Look for stages that take orders of magnitude longer than others. Often the time is dominated by an early $match that lacks an index, or by a $lookup whose foreign field is unindexed.

Step 3: Index design that holds at scale

MongoDB indexes look simple. They are not. The wrong choice at small scale becomes a six-month rewrite at large scale because dropping a multi-terabyte index takes a maintenance window.

Single-field indexes

For a simple filter:

JS
db.orders.createIndex({ customerId: 1 })

1 is ascending, -1 is descending. For single-field indexes the direction rarely matters because MongoDB can scan a B-tree backward. It matters for compound indexes.

Compound indexes and the ESR rule

The ESR rule (Equality, Sort, Range) is the most important guideline in MongoDB index design. Order the fields in your compound index in this order:

  1. Equality fields first.
  2. Sort fields next.
  3. Range fields last.

For the query:

JS
db.orders.find({
  customerId: 12345,         // Equality
  total: { $gt: 100 }        // Range
}).sort({ createdAt: -1 })   // Sort

The right index is:

JS
db.orders.createIndex({
  customerId: 1,    // E
  createdAt: -1,    // S
  total: 1          // R
})

ESR works because the equality lookup goes straight to the matching keys, the sort field walks them in order, and the range filter is the last gate. Violating the order forces an in-memory sort or extra scanning. The MongoDB team has internally referred to this rule for years; if you remember nothing else from this post, remember ESR.

Covered queries

If the projection only needs fields that are in the index, MongoDB never touches the document. The plan shows PROJECTION_COVERED with no FETCH:

JS
db.orders.find(
  { customerId: 12345 },
  { _id: 0, customerId: 1, status: 1, total: 1 }
)

db.orders.createIndex({ customerId: 1, status: 1, total: 1 })

The _id: 0 exclusion is required. _id is in every document by default but isn't in your custom index, so it would force a fetch.

Covered queries are the fastest reads MongoDB can do. For a hot dashboard query, the projection-shape index can be 10x faster than a regular index. The cost is index size: every column you add to the index multiplies the index footprint.

Multikey indexes

An index on an array field is a multikey index. MongoDB creates one index entry per array element:

JS
db.posts.createIndex({ tags: 1 })

db.posts.find({ tags: "mongodb" })

Two things to know about multikey indexes:

  1. They get big fast. A document with a 100-element array creates 100 index entries. On a large collection with unbounded arrays, the index size dwarfs the document size.
  2. You can't compound two array fields. { tags: 1, categories: 1 } is rejected if both are arrays. Compound indexes can have at most one multikey field.

The "unbounded array" rule deserves its own warning. If your schema has an array that grows over time (messages in a thread, events in a session, items in a cart), you have a multikey index sizing problem brewing. Plan for it now.

Partial indexes

Index only documents that match a filter. The classic case is "most documents are settled, you only ever query the pending ones":

JS
db.orders.createIndex(
  { customerId: 1 },
  { partialFilterExpression: { status: "pending" } }
)

The query has to include the filter expression for MongoDB to use the partial index:

JS
// Uses the index
db.orders.find({ customerId: 12345, status: "pending" })

// Cannot use it, no status filter
db.orders.find({ customerId: 12345 })

For sparse fields (like an optional archivedAt), partialFilterExpression: { archivedAt: { $exists: true } } keeps the index small.

Sparse vs partial

sparse: true is the older feature. It only indexes documents where the field exists. partialFilterExpression is strictly more powerful and works on arbitrary predicates. Prefer partial. Sparse is kept for compatibility.

Wildcard indexes

For collections where the query shape is unpredictable (often the case with flexible-schema event data), a wildcard index indexes every field path:

JS
db.events.createIndex({ "$**": 1 })

Wildcard indexes are large, slow to build, and slow to update. They're the right answer when:

  • You genuinely cannot predict which fields will be filtered.
  • The collection has bounded write rates.
  • The cost of a dedicated index per pattern would be higher than one big wildcard.

For known query patterns, a specific compound index is faster and smaller. Don't use a wildcard index as a "performance escape hatch" for a workload that has clear shapes.

Hashed indexes

Hashed indexes are mainly used for sharding (a hashed shard key gives uniform distribution). They support equality lookups but not ranges. For non-shard-key use cases, B-tree indexes are almost always the right choice.

Text indexes vs Atlas Search

MongoDB's built-in text indexes are basic: tokenization, stemming, no fuzzy match, one text index per collection. They work for simple use cases:

JS
db.posts.createIndex({ body: "text" })
db.posts.find({ $text: { $search: "mongodb performance" } })

For anything serious (faceting, fuzzy match, autocomplete, ranking), Atlas Search is the right answer. It's Lucene under the hood, integrates as a special index type, and is dramatically more capable. The cost is that it only runs on Atlas.

If you're self-hosted and need real search, run Elasticsearch or OpenSearch alongside Mongo and sync via change streams. The built-in text indexes are a placeholder, not a search engine.

TTL indexes

Auto-delete old documents:

JS
db.sessions.createIndex(
  { lastSeen: 1 },
  { expireAfterSeconds: 86400 }
)

The background TTL thread runs every 60 seconds. Documents are deleted lazily; expect a 60-second lag from the TTL value before deletion. On bulk-insert collections with TTL, deletion can lag further during write spikes.

Building indexes without blocking

createIndex blocks writes by default through MongoDB 4.0. In 4.2+, the default switched to an online build that does not block reads or writes. For older versions, use { background: true }:

JS
db.orders.createIndex({ customerId: 1 }, { background: true })

For huge collections, even online index builds take hours. Plan for it.

Index intersection

MongoDB can combine multiple single-field indexes for a single query. In practice, it's almost always slower than the equivalent compound index. Don't rely on index intersection as a substitute for designing the right compound index.

Finding unused indexes

JS
db.orders.aggregate([
  { $indexStats: {} },
  { $project: { name: 1, accesses: 1 } },
  { $sort: { "accesses.ops": 1 } }
])

Indexes with accesses.ops: 0 haven't been used since the stats were last reset. As with Postgres, check stats from the primary (or each replica if reads go to secondaries), and use at least a 24-hour window.

Step 4: Schema design impact on performance

This is where MongoDB diverges from SQL. Your schema is the most consequential performance decision you'll make, and it's also the hardest to change later.

Embedded vs referenced documents

The MongoDB design principle: data that is accessed together should be stored together. For a one-to-few relationship (a user and their addresses), embed:

JS
{
  _id: ObjectId("..."),
  name: "Jane",
  addresses: [
    { street: "...", city: "..." },
    { street: "...", city: "..." }
  ]
}

One round-trip, one document, no joins. Perfect.

For a one-to-many relationship where "many" can grow large (a user and their orders), reference:

JS
// users
{ _id: ObjectId("user1"), name: "Jane" }

// orders
{ _id: ObjectId("order1"), userId: ObjectId("user1"), total: 99.99 }

You'll need a query (or $lookup) to join. The trade-off: indexing the foreign field is cheap, and the orders collection can grow forever without bloating user documents.

The mistake every team makes once: embedding an array that grows without bound. Comments in a post, messages in a thread, line items in a long-running cart. Eventually the parent document hits the 16MB document size limit. By then it's painful to migrate.

The 16MB document size limit

Single documents in MongoDB cannot exceed 16MB. Hit this and writes start failing with errors that confuse the engineers who didn't design the schema. Things to watch:

  • Any array that grows over time without explicit caps.
  • JSON blobs from third-party APIs stored as subdocuments.
  • Log entries accumulated as embedded arrays.

The fix is usually the bucket pattern (below) or moving the array out to its own collection.

The bucket pattern for time-series

For high-volume time-series data (sensor readings, click events), the bucket pattern groups records into time-bounded documents:

JS
{
  _id: ObjectId("..."),
  deviceId: "device-1",
  startTime: ISODate("2026-05-28T00:00:00Z"),
  endTime: ISODate("2026-05-28T00:15:00Z"),
  count: 900,
  readings: [
    { ts: ISODate("..."), temp: 22.5 },
    { ts: ISODate("..."), temp: 22.6 },
    // ... up to 900
  ]
}

One document per device per 15-minute window. Reads of "show me the last day" become 96 documents instead of 86,400.

Time-series collections (MongoDB 5.0+)

Since 5.0, MongoDB has a native time-series collection type that implements bucket-pattern internals automatically:

JS
db.createCollection("sensorData", {
  timeseries: {
    timeField: "timestamp",
    metaField: "deviceId",
    granularity: "minutes"
  }
})

For new projects with time-series workloads, use time-series collections. The bucket pattern is the right call for older deployments or when you need finer control over the bucket boundaries.

Schema validation cost

JSON Schema validation on writes (via validator) is convenient but has a per-write cost. For high-volume insert workloads, validate at the application boundary and skip schema validation on the collection. The exception: validation is the right tool when the writer is untrusted or when you need strong invariants for compliance.

Choosing _id

The default ObjectId is fine for most cases. It encodes a timestamp in the first 4 bytes, which gives you "newest first" reverse-sort behavior almost free. Two cases to override:

  1. Natural keys. If a document has a real unique identifier (an order number, a username), use it as _id. Saves an index.
  2. Time-bucketed shard keys. Hashed shard keys distribute writes evenly. Monotonic shard keys (like raw ObjectId) concentrate writes on one shard. Don't use raw ObjectId as a shard key.

Step 5: Aggregation pipeline optimization

Pipelines are where MongoDB earns its keep and also where it tends to fall over.

Move $match before $lookup, $group, $unwind

JS
// Slow: $lookup runs on every document, then filters
db.orders.aggregate([
  { $lookup: { from: "customers", localField: "customerId",
               foreignField: "_id", as: "customer" } },
  { $match: { status: "pending" } }
])

// Fast: filter first
db.orders.aggregate([
  { $match: { status: "pending" } },
  { $lookup: { from: "customers", localField: "customerId",
               foreignField: "_id", as: "customer" } }
])

The aggregation optimizer often pushes $match early on its own, but only when it can prove the rewrite is safe. Be explicit. The aggregation framework has many such optimizations documented in the manual; reading through them once is worth an hour.

$lookup is slower than denormalization

$lookup is MongoDB's join. It's an order of magnitude slower than the equivalent embedded query, especially at scale. Use it when:

  • The joined collection is small and unchanging (reference data).
  • The join happens rarely.
  • The foreign field is indexed.

Avoid it on the hot path. If you find yourself doing $lookup in every read, the schema is wrong. Denormalize: copy the joined field onto the parent at write time. Updates become slightly more complex; reads become an order of magnitude faster.

Index the foreign field, always

The single most common $lookup mistake:

JS
// orderItems doesn't have an index on orderId
db.orders.aggregate([
  { $lookup: { from: "orderItems", localField: "_id",
               foreignField: "orderId", as: "items" } }
])

Each parent document triggers a collection scan on orderItems. The fix:

JS
db.orderItems.createIndex({ orderId: 1 })

Profile any aggregation with $lookup and confirm the foreign field is indexed. There's no graceful version of this error; it gets slower as the joined collection grows.

$project early to reduce data flow

If your pipeline doesn't need a 5KB field, project it out early:

JS
db.events.aggregate([
  { $match: { timestamp: { $gte: ISODate("2026-05-28") } } },
  { $project: { _id: 1, userId: 1, eventType: 1 } },
  { $group: { _id: "$userId", count: { $sum: 1 } } }
])

Less data through each stage means less work for $group, less memory pressure, less network if you're crossing shards.

$merge for materialized aggregations

For aggregations you run repeatedly (a daily rollup, an hourly dashboard), don't recompute from scratch:

JS
db.events.aggregate([
  { $match: { day: ISODate("2026-05-28") } },
  { $group: { _id: { userId: "$userId" }, count: { $sum: 1 } } },
  { $merge: {
      into: "daily_user_events",
      whenMatched: "merge",
      whenNotMatched: "insert"
  }}
])

The dashboard reads from daily_user_events. Update it on a schedule from the source. This is the MongoDB version of a materialized view, and it's more flexible than the SQL equivalent.

allowDiskUse for large aggregations

For pipelines that exceed 100MB at any stage, set { allowDiskUse: true }. As of MongoDB 6.0, this is the default for aggregations. For older versions, set it explicitly on every pipeline that might be large:

JS
db.events.aggregate(pipeline, { allowDiskUse: true })

Pipelines that spill to disk are slow, so the fix is usually a better index or schema, not the spill flag alone. Enabling spill is the difference between "slow query" and "no result."

Pipeline patterns to avoid

  • $lookup followed by $unwind followed by $match on the unwound field. The optimizer often can't push the filter into the lookup. Restructure so the filter happens before the lookup.
  • $sort without an index, on a large dataset, without $limit. This blows up memory. Sort early after a $match that uses an index, or add a $limit.
  • Deep $facet. $facet runs sub-pipelines in parallel and merges results. Powerful for dashboards, but each sub-pipeline costs full memory. Don't put 10 sub-pipelines in one $facet unless you have a reason.

Step 6: Rewrite slow queries

Some queries are slow because of patterns the planner can't optimize. These are mechanical fixes.

Avoid $where and JavaScript

$where runs JS against every document. It cannot use indexes and the overhead is high:

JS
// Slow: collection scan, JS per doc
db.orders.find({ $where: "this.total > this.deposit * 2" })

// Fast: rewrite with $expr
db.orders.find({
  $expr: { $gt: ["$total", { $multiply: ["$deposit", 2] }] }
})

$expr runs natively and can use indexes (in some cases). $where is the slowest expression family MongoDB has. The fact that it works is the only reason to use it; if there's any alternative, take it.

$in over many $or

JS
// Slow: each $or branch evaluated separately
db.orders.find({
  $or: [
    { customerId: 1 }, { customerId: 2 }, { customerId: 3 },
    // ... 100 more
  ]
})

// Fast: single $in
db.orders.find({ customerId: { $in: [1, 2, 3, /* ... */] } })

$in is one index seek per term. $or with many branches is many index seeks plus a deduplication step.

Regex anchoring

JS
// Uses the index on name
db.products.find({ name: /^widget/ })

// Cannot use the index
db.products.find({ name: /widget/ })

// Case-insensitive: also cannot use the index (without collation)
db.products.find({ name: /^widget/i })

For case-insensitive equality on indexed fields, define the index with a case-insensitive collation:

JS
db.products.createIndex(
  { name: 1 },
  { collation: { locale: "en", strength: 2 } }
)

// And query with the same collation
db.products.find({ name: "Widget" }).collation({ locale: "en", strength: 2 })

Project only what you need

Returning the whole document when you need three fields wastes network and CPU:

JS
// Slow: returns everything
db.orders.find({ customerId: 12345 })

// Faster: only the fields you need
db.orders.find(
  { customerId: 12345 },
  { _id: 0, id: 1, status: 1, total: 1, createdAt: 1 }
)

This is doubly important on large documents and is required for covered queries.

Batch with bulkWrite

A loop of single-document writes is slow because of the per-operation round-trip:

JS
db.orders.bulkWrite([
  { updateOne: { filter: { _id: 1 }, update: { $set: { status: "shipped" } } } },
  { updateOne: { filter: { _id: 2 }, update: { $set: { status: "shipped" } } } },
  // ... batch up to 1000
], { ordered: false })

ordered: false lets the server parallelize and continue past failures. Use it unless you genuinely need sequential application.

batchSize and unbounded cursors

A find() returns a cursor. The driver fetches documents in batches (default 101 documents, then 16MB or 1000 documents per subsequent batch). For huge result sets, set batchSize to avoid memory pressure on the client:

JS
db.events.find({}).batchSize(500).forEach(...)

For large data exports, use noCursorTimeout() so the cursor doesn't expire mid-export, but always close cursors explicitly when done.

Hint when the planner picks wrong

If the planner consistently picks a worse index than you can pick by hand, hint() forces a specific one:

JS
db.orders.find({...}).hint({ customerId: 1, status: 1 })

Use this for debugging. Don't ship hinted queries to production unless you have a specific reason; you're locking out future planner improvements.

Step 7: WiredTiger and configuration

A handful of settings move the needle.

WiredTiger cache size

The most important setting. WiredTiger caches data and indexes in memory. The default is 50% of (RAM - 1GB), or 256MB, whichever is larger.

On a dedicated MongoDB host, the default is usually right. If you're co-located with other services, set it explicitly in mongod.conf:

YAML
storage:
  wiredTiger:
    engineConfig:
      cacheSizeGB: 8

The cache must be big enough to hold your working set: the indexes for your active queries, plus the documents those queries return. If the working set exceeds the cache, every query incurs disk reads, and performance falls off a cliff.

Check cache usage:

JS
db.serverStatus().wiredTiger.cache

Look at bytes currently in the cache versus maximum bytes configured. If it's near max and pages read into cache is high, you're cache-bound. The fix is more RAM, smaller indexes, or sharding.

Connection pool sizing

MongoDB drivers maintain connection pools. The default size varies by driver, typically 100. For high-concurrency apps, you may need to bump it. For serverless apps, you may need to drop it (or pool externally).

Each connection has nontrivial memory overhead on the server. As with Postgres, more connections is not better. The sweet spot is usually well below what apps default to.

Read concern and write concern trade-offs

These knobs trade safety for speed. The defaults are reasonable; understand them before changing.

  • writeConcern: { w: 1 } acknowledges after the primary writes. Fastest, can lose data on primary failover.
  • writeConcern: { w: "majority" } waits for a majority of replicas to acknowledge. Default in many drivers. Safe; slower.
  • writeConcern: { w: "majority", j: true } also waits for the journal flush. Most durable; slowest.
  • readConcern: { level: "local" } reads what's on the local node, including possibly-rollbackable writes.
  • readConcern: { level: "majority" } reads only data acknowledged by a majority. Slightly slower; immune to rollbacks.

Pick per operation, not globally. A user-facing read of a non-critical field can use local. A "did my just-written payment succeed?" check should use majority.

Oplog sizing

The oplog (operations log) is the replication source. On a write-heavy workload, an undersized oplog forces full resyncs when a replica falls behind. Default is 5% of disk; bump it if your write rate is high:

YAML
replication:
  oplogSizeMB: 50000

Check the oplog window:

JS
rs.printReplicationInfo()

If the window is less than a few hours, increase the oplog size. Brief network blips become full resyncs without enough oplog history.

Journal settings

Journaling defaults are safe and fast. Don't change them unless you've measured a need.

Step 8: Common slow query patterns

Working set larger than cache

The defining MongoDB performance problem. Symptoms:

  • IXSCAN is showing in the plan, but executionTimeMillis is still high.
  • db.serverStatus().wiredTiger.cache shows bytes read into cache rising fast.
  • mongostat's disk column shows constant I/O.

Fixes, in order of preference:

  1. Reduce the working set: drop unused indexes, slim down documents, archive cold data.
  2. Add a covering index for the hottest query so it doesn't fetch documents.
  3. Scale the cache by adding RAM.
  4. Shard, so each shard's working set fits in its cache.

COLLSCAN on a large collection

The classic missing-index issue. system.profile shows the query; explain() confirms. createIndex is the fix. Verify with explain() afterward that the planner picks the new index.

Missing index on $lookup foreign field

JS
db.orderItems.createIndex({ orderId: 1 })

There is no other way to make $lookup perform.

Unbounded array growth

A document with a 100K-element array is a permanent performance problem. Find them:

JS
db.orders.aggregate([
  { $project: { itemCount: { $size: { $ifNull: ["$items", []] } } } },
  { $sort: { itemCount: -1 } },
  { $limit: 10 }
])

Refactor schemas where any array can grow without bound. Move the contents to a separate collection with a reference back.

Hot shard

On a sharded cluster, an unevenly-distributed shard key sends most traffic to one shard. Check with sh.status() and look at the chunk distribution. Common causes:

  • Monotonic shard key (raw ObjectId, autoincrementing IDs).
  • Shard key that has low cardinality.
  • Shard key that doesn't match query patterns, so most queries fan out to every shard.

Resharding is possible since MongoDB 5.0 (reshardCollection), but it's a heavy operation. Choose the shard key carefully the first time.

Idle connections

Drivers with aggressive connection-pool sizing can leave many idle connections open. Each one costs memory. Check with db.serverStatus().connections. If you have thousands of connections and most are idle, drop the pool size in the driver.

Stale plan cache

Rare but real. If a query that should use a new index keeps using the old one even after ANALYZE-equivalent statistics updates, the plan cache may be stuck. Clear it:

JS
db.orders.getPlanCache().clear()

Plan caches reset on restart or index change, so this is mainly a diagnostic tool, not an ongoing maintenance task.

The N+1 problem with ODMs

Mongoose's populate() and similar features make this easy to write without realizing:

JS
const orders = await Order.find({ customerId: 123 })
// then for each order:
for (const o of orders) {
  o.items = await OrderItem.find({ orderId: o._id })
}

Replace with $lookup or a single $in query:

JS
const orders = await Order.find({ customerId: 123 })
const ids = orders.map(o => o._id)
const items = await OrderItem.find({ orderId: { $in: ids } })

Mongoose's .populate() automates this and uses $in under the hood, so it's better than a manual loop. Still verify with the profiler.

Deep skip()

JS
db.orders.find({}).sort({ _id: 1 }).skip(10000).limit(20)

Same problem as SQL OFFSET. Use range-based pagination on _id:

JS
db.orders.find({ _id: { $gt: lastSeenId } }).sort({ _id: 1 }).limit(20)

Step 9: When tuning isn't enough

Sometimes the architecture is the limit.

Sharding

For workloads that exceed a single replica set's capacity (whether by data size, write throughput, or working set), shard. The shard key choice is the most consequential decision, and it's hard to change later. Spend more time on it than feels natural.

Hashed shard keys give even distribution at the cost of forcing scatter-gather for range queries. Range-based shard keys preserve range query locality but risk hot shards.

Reading from secondaries

For workloads where most reads can tolerate slight staleness, route reads to secondaries:

JS
db.orders.find({}).readPref("secondaryPreferred")

This offloads read load from the primary. Don't use it for "did my write succeed" reads. Replication lag during heavy writes can be seconds; verify lag in your monitoring before relying on this.

Atlas Search

For text-heavy workloads (search, autocomplete, fuzzy match), Atlas Search is the right tool. The built-in text indexes are not.

Time-series collections

Migrate to native time-series collections (MongoDB 5.0+) for any time-series workload. The implementation is more efficient than rolling your own bucket pattern.

Change streams for derived collections

Instead of running expensive aggregations on demand, maintain derived collections via change streams:

JS
const pipeline = [
  { $match: { "fullDocument.status": "completed" } }
]
const changeStream = db.orders.watch(pipeline)
changeStream.on("change", ({ fullDocument }) => {
  // Update a denormalized analytics collection
})

The pattern is similar to materialized views, but reactive. Useful when the source data is high-volume and consumers are read-heavy.

The tools worth installing

ToolWhat it doesCost
Database profilerSlow op captureFree, built in
mongostat, mongotopReal-time server statsFree, built in
MongoDB CompassGUI with explain visualizer and schema analyzerFree
mtoolsLog analysis (mloginfo, mlogfilter, mlogvis)Free
Atlas Performance AdvisorLive index recommendationsFree with Atlas
Atlas Query ProfilerPersistent slow-query historyFree with Atlas
Atlas SearchLucene-backed full-text searchAtlas-only, paid
mongocliAtlas/Ops Manager CLIFree
Studio 3TPolished GUI clientPaid

For most teams, the right starting set is the profiler enabled with sampling, mtools for log analysis, and Compass for plan visualization. If you're on Atlas, the Performance Advisor pays for itself in saved time.

Quick checklist

When you encounter a slow query:

  1. Check the profiler. Is this query a top consumer, or a one-off? Fix the consumers first.
  2. Run explain("executionStats"). Look at totalDocsExamined vs nReturned. If the ratio is bad, you have a missing or wrong index.
  3. Look for COLLSCAN on a large collection. That's a missing index.
  4. Apply ESR to compound indexes. Equality, then Sort, then Range.
  5. Project to a covered query when reading hot dashboards. Look for PROJECTION_COVERED in the plan.
  6. Move $match early in aggregation pipelines. Push filters before $lookup, $group, $unwind.
  7. Index every $lookup foreign field, every time.
  8. Check WiredTiger cache if IXSCAN is fine but timing is bad. Working set may not fit.
  9. Avoid the dragons. $where, unanchored regex, unbounded arrays, deep skip, monotonic shard keys.

Most slow MongoDB queries come down to a missing index, a wrong index order, or a schema decision that didn't scale. The tools above turn each of those from a research project into a few minutes of work. The expensive failure mode is the slow-creep version: indexes that worked at small scale but blow past the cache as data grows. Watch your working set size as carefully as you watch query times.

Keep Reading