A missing index means MongoDB must scan every document in a collection to find matches. With 100,000 documents, that's 100,000 reads for every query. With the right index, it's 1–5 reads. The difference is not just performance - it's the difference between a working product and one that falls over under load.
The Cost of Missing an Index
Let's start with a concrete example:
// Collection: orders (500,000 documents)
// Query: get a user's recent orders, sorted by date
const orders = await Order.find({ userId: req.user.id })
.sort({ createdAt: -1 })
.limit(10);Without an index, MongoDB does a full collection scan (COLLSCAN). On 500,000 documents, this takes seconds and blocks the event loop. Let's measure it.
Analyzing Queries with explain()
explain('executionStats') is your primary diagnostic tool. It tells you exactly what MongoDB did to execute your query.
const result = await Order.find({ userId: req.user.id })
.sort({ createdAt: -1 })
.explain('executionStats');
console.log(result.executionStats);
// {
// totalDocsExamined: 500000, ← examined every document
// nReturned: 47, ← only needed 47
// executionTimeMillis: 2340, ← 2.3 seconds
// stage: 'COLLSCAN', ← full scan, no index
// }The signal: totalDocsExamined vastly greater than nReturned means you're missing an index. The goal is a ratio close to 1:1.
Single Field Indexes
The simplest fix is indexing the filter field. In Mongoose:
const OrderSchema = new Schema({
userId: { type: Schema.Types.ObjectId, ref: 'User', index: true },
status: String,
createdAt: Date,
});This helps the filter, but not the sort. MongoDB still has to sort 47 matching documents in memory - fine here, but with thousands of results this causes a slow SORT stage.
Compound Indexes: The Real Solution
A compound index on both the filter field and the sort field lets MongoDB satisfy the entire query without any in-memory sorting:
// Index on userId (filter) + createdAt (sort direction matches query)
OrderSchema.index({ userId: 1, createdAt: -1 });// explain() after adding the compound index:
// {
// totalDocsExamined: 47, ← only docs we need
// nReturned: 47,
// executionTimeMillis: 3, ← 780x faster
// stage: 'IXSCAN', ← using the index
// }The ESR Rule - the correct field order for compound indexes:
- Equality fields first - fields used with exact match (
userId: id) - Sort fields second - fields used in
.sort() - Range fields last - fields used with
$gte,$lte,$in
Following ESR means MongoDB can use the index for filter, sort, AND range in a single pass.
Text Indexes for Search
For full-text search on string fields, use a text index:
ProductSchema.index({ name: 'text', description: 'text' });
// Query
const products = await Product.find(
{ $text: { $search: 'wireless headphones' } },
{ score: { $meta: 'textScore' } },
).sort({ score: { $meta: 'textScore' } });Text indexes support stemming (searching "running" matches "run"), stop words, and multiple languages. Only one text index is allowed per collection, but it can cover multiple fields.
Partial Indexes
Index only the subset of documents you actually query. This saves memory and speeds up writes:
// Only index active users - inactive users are never queried by email
UserSchema.index(
{ email: 1 },
{
unique: true,
partialFilterExpression: { status: 'active' },
},
);
// Only index pending orders - completed orders are read by userId, not status
OrderSchema.index(
{ status: 1, createdAt: -1 },
{
partialFilterExpression: { status: 'pending' },
},
);TTL Indexes for Expiring Data
TTL indexes automatically delete documents after a time period - perfect for sessions, OTPs, rate limiting entries, and temporary tokens:
const SessionSchema = new Schema({
token: String,
userId: Schema.Types.ObjectId,
expiresAt: { type: Date, required: true },
});
// MongoDB automatically deletes documents when expiresAt is in the past
SessionSchema.index({ expiresAt: 1 }, { expireAfterSeconds: 0 });TTL indexes run as a background job every 60 seconds - not exactly on the dot, but close enough for most use cases.
Finding and Removing Unused Indexes
Every index slows down writes (MongoDB must update the index on every insert, update, and delete). Unused indexes are pure cost:
// Find indexes and their access counts
db.orders.aggregate([{ $indexStats: {} }]).forEach((stat) => {
console.log({
name: stat.name,
ops: stat.accesses.ops,
since: stat.accesses.since,
});
});
// Drop unused indexes
db.orders.dropIndex('old_index_name');Run this periodically. An index that was used last month but not this month might be for a deprecated query pattern. Drop it.
Index Coverage: The Fastest Queries
A covered query is one where MongoDB can answer the query entirely from the index, without touching the documents themselves. These are the fastest possible queries:
// Index covers all fields in the query and projection
OrderSchema.index({ userId: 1, status: 1, createdAt: -1 });
// This query is covered - MongoDB never reads the documents
const result = await Order.find(
{ userId: id, status: 'completed' },
{ userId: 1, status: 1, createdAt: 1, _id: 0 }, // projection matches index
).sort({ createdAt: -1 });Common Indexing Mistakes
- Over-indexing - every index has a write cost. Only index queries you run frequently. Aim for <5 indexes per collection for write-heavy collections.
- Wrong sort direction - an index on
{ createdAt: 1 }serves ascending and descending sorts equally. Mismatched directions in compound indexes are a problem:{ a: 1, b: 1 }cannot serve.sort({ a: 1, b: -1 }). - Leading field mismatch - an index on
(userId, status)helps queries filtering byuserId, but does NOT help queries filtering only bystatus. The leading field must be present in the query. - Skipping Atlas Performance Advisor - MongoDB Atlas analyzes your actual query patterns and recommends missing indexes. Check it weekly.
Quick Checklist Before You Ship
- Run
explain('executionStats')on your 10 most common queries - Ensure
totalDocsExamined / nReturned < 2for each - All queries with
.sort()have a compound index following ESR - Soft-deleted or status-filtered collections use partial indexes
- Expiring data uses TTL indexes (not a cron job deleting old records)
- Run
$indexStatsin production to find unused indexes and drop them