A client's dashboard was timing out under normal load. Their MongoDB collection had grown to 2 million documents and nobody had revisited the query strategy since launch. In four focused hours, we cut the slowest endpoint from 4.2 seconds to 380 milliseconds. Here's exactly what we changed.
Step 1: Run explain() on Every Slow Query
Use db.collection.find(yourQuery).explain('executionStats') and look at totalDocsExamined vs nReturned. If those numbers are wildly different (e.g. 200,000 examined to return 10 documents), you're doing a collection scan and need an index. This single step identified 80% of our problems in the first 30 minutes.
Key Optimizations We Applied
- Added compound indexes on the most common filter + sort field combinations
- Replaced nested .populate() chains with a single $lookup aggregation pipeline
- Added { status: 1 } index to filter by status — this field was in every query but never indexed
- Used projection to return only required fields instead of full documents
- Moved expensive aggregations to a scheduled job that pre-computes summary documents
Schema Design Lessons
The root cause was a schema designed for relational thinking in a document database. MongoDB rewards denormalization. If you always fetch a user's name alongside their orders, embed the name in the order document. Accept some data duplication in exchange for queries that touch a single collection. The MongoDB documentation's golden rule: structure data the way your application queries it, not the way you'd normalize it in SQL.