MongoDB performance checklist: best practices explained
Slow MongoDB queries can silently drain your application's performance budget, causing cascading latency across your stack. We've seen production incidents where a single unindexed query brought an entire ride-hailing platform to its knees during peak hours. In this article, we'll walk through the MongoDB performance checklist: best practices explained, focusing on practical techniques that teams have used to reclaim milliseconds and restore confidence in their databases.
Indexes for every frequent query
Indexes are your first line of defense. Without them, MongoDB performs collection scans, touching every document even when you only need a few. If you're running the same query repeatedly in production, you need an index supporting it.
Let's say you're building a user management system for a SaaS platform. Your application fetches active users by their email and company ID daily:
db.users.find({ email: "alice@example.com", companyId: ObjectId("60d5ec49c1234") })
Without an index, MongoDB scans all documents. With a compound index, it locates the result in microseconds:
db.users.createIndex({ email: 1, companyId: 1 })
The order matters. Put the equality field first (email), then the filtering field (companyId). This is known as the ESR rule: Equality, Sort, Range. When you add a sort, place it between equality and range fields.
Monitor which queries are slow using the profiler. Set the profiler level to capture operations slower than 100ms:
db.setProfilingLevel(1, { slowms: 100 })
Then inspect slow operations:
db.system.profile.find({ millis: { $gt: 100 } }).sort({ ts: -1 }).limit(5)
Each query in the results either has an index or it doesn't. The executionStats show totalDocsExamined versus nReturned. If those numbers are far apart, you're scanning too many documents. Build an index and watch the ratio improve.
Projections to reduce payload
Indexes speed up finding documents, but payload size affects network and memory costs. Projections let you fetch only the fields you need, reducing bandwidth and CPU on both the server and client.
Imagine a travel booking system querying flights. A flight document might contain detailed aircraft specs, crew assignments, and historical delays. When rendering a search results page, you only need the flight number, departure time, arrival time, and price:
db.flights.find(
{ departureDate: { $gte: new Date("2024-01-15") } },
{ flightNumber: 1, departure: 1, arrival: 1, price: 1 }
)
The second argument to find() is the projection. The 1 means include that field; 0 means exclude it. MongoDB always includes _id unless you explicitly suppress it with _id: 0.
This is especially powerful in aggregation pipelines. Use $project early to filter fields before expensive stages like $group or $lookup:
db.orders.aggregate([
{ $match: { status: "completed" } },
{ $project: { customerId: 1, totalAmount: 1, createdAt: 1 } },
{ $group: { _id: "$customerId", total: { $sum: "$totalAmount" } } }
])
By projecting early, you shrink the working set for the group stage. Less data in memory means faster aggregation and lower resource consumption.
Working set fits in RAM
MongoDB performs best when your working set—the data your application actively reads and writes—fits in RAM. Once you spill to disk, performance degrades sharply. We've seen latency jump from 5ms to 500ms when the working set exceeded available memory.
A trading platform might ingest price ticks for 500 active stocks every second. The working set includes the last hour of tick data (36 million ticks) plus live order books (thousands of documents). If this fits in RAM, queries return instantly. If it doesn't, frequent disk reads kill throughput.
To estimate your working set, use the db.stats() and db.collection.stats() commands:
db.stats()
// Returns: dataSize (actual data), indexSize, avgObjSize, etc.
db.priceTicksActive.stats()
// Shows memory footprint of that collection
Also check MongoDB's built-in memory metrics in the monitoring dashboard. If you see frequent page faults or eviction, the working set is too large for your RAM allocation.
Design your schema to keep the working set lean. Archive historical data to a cold collection or separate database. Implement TTL indexes to auto-delete old documents:
db.sessionLogs.createIndex({ createdAt: 1 }, { expireAfterSeconds: 86400 })
This index automatically removes documents older than 24 hours, keeping your working set fresh and small.
Avoid large in-memory sorts
Sorting large result sets in memory is expensive. MongoDB loads the unsorted results into RAM, sorts them, then returns them. For datasets with thousands or millions of documents, this becomes a bottleneck.
Consider a customer support system that queries open tickets and sorts by priority and creation date:
db.tickets.find({ status: "open" }).sort({ priority: -1, createdAt: -1 })
If there are 100,000 open tickets, MongoDB pulls all of them into memory and sorts them. This consumes significant RAM and CPU.
The solution is to index the sort fields in the same order as your sort clause:
db.tickets.createIndex({ priority: -1, createdAt: -1 })
With this index, MongoDB returns results already sorted, eliminating the in-memory sort. The query becomes nearly instant.
Combine the sort index with your query filter for maximum efficiency. If you're filtering by status and sorting by priority, use a compound index:
db.tickets.createIndex({ status: 1, priority: -1, createdAt: -1 })
Also be aware of MongoDB's limit on in-memory sorts: 32MB by default. Any sort exceeding this limit will fail unless you index it. If your sort can't be indexed and grows beyond 32MB, consider pagination or breaking the query into smaller chunks.
Common mistakes
- Creating indexes without profiling. Not every index helps. Profile your queries first, then index the slow ones. Too many indexes waste memory and slow down writes.
- Projecting fields you don't need. Some teams fetch entire documents then filter in application code. Always push projections to the database.
- Ignoring the working set. Teams assume their database will handle any data size. Monitor working set memory; if it exceeds RAM, either add memory or reduce data.
- Sorting without indexes. Unindexed sorts consume memory and CPU. Always check if your sort is covered by an index using
explain(). - Over-normalizing with lookups. Each
$lookupstage in an aggregation is a join. Too many lookups can be slower than denormalizing data. Measure both approaches.
Key takeaways
- Index your frequent queries using the ESR rule (Equality, Sort, Range) to avoid collection scans.
- Use projections to fetch only the fields your application needs, reducing payload size and memory pressure.
- Keep your working set in RAM by archiving old data and using TTL indexes to auto-delete expired documents.
- Index sort fields to avoid expensive in-memory sorts, which consume memory and CPU and fail beyond 32MB.
- Always profile before optimizing; use
db.setProfilingLevel()andexplain()to identify bottlenecks.






Comments
Loading comments…