Skip to content

Databases

Stop Guessing at MongoDB Performance: Read the Explain Plan

A slow query is not a mystery. explain('executionStats') tells you exactly what happened — here is how to read it, the four numbers that matter, and the index fixes each symptom points to.

3 min read0 views
  • #mongodb
  • #performance
  • #indexing
  • #database
  • #backend

An endpoint went from 40 ms to 1.8 s over a few months. Nothing in the code had changed — the collection had just grown past the point where a missing index stopped being free.

The fix took eleven minutes once I stopped guessing and read the plan.

The one command

db.blogs
  .find({ published: true, tags: "nextjs" })
  .sort({ publishedAt: -1 })
  .limit(10)
  .explain("executionStats");

executionStats actually runs the query and reports what happened. (queryPlanner, the default, only shows what Mongo intends to do.) In Mongoose:

const plan = await Blog.find({ published: true, tags: 'nextjs' })
  .sort({ publishedAt: -1 })
  .limit(10)
  .explain('executionStats');
 
console.dir(plan, { depth: null });

The four numbers

The output is verbose. Ignore almost all of it and look at these:

{
  "executionStats": {
    "nReturned": 10,
    "executionTimeMillis": 1834,
    "totalKeysExamined": 0,
    "totalDocsExamined": 84213,
    "executionStages": { "stage": "COLLSCAN" }
  }
}
Field What it means What you want
nReturned documents actually returned your page size
totalDocsExamined documents read off disk nReturned
totalKeysExamined index entries walked nReturned
stage access method IXSCAN, never COLLSCAN

The single most useful signal is the ratio totalDocsExamined / nReturned. Examining 84,213 documents to return 10 means you're reading the whole collection and throwing away 99.99% of it.

COLLSCAN on anything but a tiny lookup table is a bug.

Fix one: the ESR rule

A compound index isn't just "the fields in the query." Order matters, and the ordering rule is Equality, Sort, Range:

db.blogs.find({
  published: true,                                    // Equality
  publishedAt: { $gte: ISODate("2026-01-01") }        // Range
}).sort({ views: -1 });                               // Sort
 
// Correct order: E → S → R
db.blogs.createIndex({ published: 1, views: -1, publishedAt: 1 });

Put the range field before the sort field and MongoDB can no longer walk the index in sorted order — it collects everything, then sorts in memory. Re-run the explain and you'll see it:

{ "stage": "SORT", "memLimit": 33554432, "usedDisk": true }

Once a blocking sort exceeds 32 MB, the query fails outright unless allowDiskUse is set — and disk sorts are orders of magnitude slower. A SORT stage in your plan is a missing or misordered index, every time.

Fix two: cover the query

If the index contains every field the query touches, MongoDB answers from the index alone and never reads a document. That's a covered query, and it shows up as totalDocsExamined: 0.

db.blogs.createIndex({ published: 1, publishedAt: -1, title: 1, slug: 1 });
 
db.blogs
  .find({ published: true }, { _id: 0, title: 1, slug: 1 })
  .sort({ publishedAt: -1 });

Two requirements people miss: the projection must exclude _id (it's included by default and isn't in the index), and every field in filter, sort and projection must be present in the index.

For list and card views — exactly what a blog index page needs — this is close to free.

Fix three: stop over-fetching

The fastest index in the world can't rescue a query that pulls 40 KB of markdown per row to render a card:

// ✗ Loads full article bodies to render titles
const posts = await Blog.find({ published: true }).sort({ publishedAt: -1 });
 
// ✓ Only what the card renders — and .lean() skips hydrating documents
const posts = await Blog.find({ published: true })
  .select('title slug excerpt tags publishedAt readingMinutes')
  .sort({ publishedAt: -1 })
  .limit(12)
  .lean();

.lean() is a real win at scale — it returns plain objects instead of full Mongoose documents, skipping getters, virtuals and change tracking. For read-only paths it routinely halves response time, and it also gives you objects that are safe to pass into React Server Components.

Fix four: paginate with a cursor, not a skip

skip(n) walks and discards n documents. Page 1 is instant, page 500 is not:

// ✗ O(n) — degrades linearly with page depth
Blog.find(filter).sort({ publishedAt: -1 }).skip(page * 20).limit(20);
 
// ✓ O(log n) — jumps straight into the index
Blog.find({ ...filter, publishedAt: { $lt: cursor } })
  .sort({ publishedAt: -1 })
  .limit(20);

Return the last item's publishedAt as the next cursor. For infinite-scroll and feed endpoints this is strictly better; for a 15-post blog index, skip is fine. Know which one you're building.

Finding the queries worth fixing

Don't guess at which endpoint is slow — let the database tell you. Enable the profiler for anything over 100 ms:

db.setProfilingLevel(1, { slowms: 100 });
 
db.system.profile
  .find({ millis: { $gt: 100 } })
  .sort({ ts: -1 })
  .limit(20);

On Atlas, the Performance Advisor does this continuously and suggests indexes with expected impact. It is genuinely good and I check it before adding anything by hand.

Indexes are not free

Every index costs write throughput and RAM. Before adding one:

db.blogs.aggregate([{ $indexStats: {} }]);

accesses.ops shows how many times each index has actually been used since the last restart. Zero means you're paying for nothing — drop it.

Also watch for prefix redundancy. { a: 1, b: 1, c: 1 } already serves queries on { a } and { a, b }. A separate { a: 1 } index is pure overhead.

The checklist

  1. explain("executionStats") on the slow query.
  2. COLLSCAN? Add an index.
  3. SORT stage? Reorder it using ESR.
  4. totalDocsExamined ≫ nReturned? Tighten the filter or cover the query.
  5. Fetching fields you don't render? .select() and .lean().
  6. Deep pagination? Switch to a cursor.
  7. $indexStats to drop what nothing uses.

Four numbers, one command. Performance work stops being guesswork the moment you read the plan.