A slow MongoDB aggregation is almost always a pipeline that does the expensive work before it does the cheap work: it scans the entire collection, then filters down to the fifty documents you wanted. That aggregation has more stages than a touring band, and about the same overnight costs. The fix is rarely a bigger machine. It is order. Move the stages that can use an index to the front, and the pipeline starts from a handful of documents instead of all of them.
Read the pipeline's plan first
Same as a find, an aggregation has an explain() and it tells you where the time goes:
db.orders.aggregate(
[
{ $match: { status: "shipped" } },
{ $group: { _id: "$userId", total: { $sum: "$amount" } } }
],
{ explain: true }
)
Look at the very first stage. An IXSCAN means the pipeline started from an index and only touched matching documents. A COLLSCAN means it read every document in the collection before the $group ran. The aggregation explain output shows the stage breakdown; the first stage is where the win is.
Filter and sort before you transform
Here is the rule that makes aggregations fast: only the stages before the first document-transforming stage can use a collection index. A $match or $sort at the top can hit an index. The same $match after a $group, $unwind, or a $project that changes the shape cannot, because the documents flowing through are computed values the index knows nothing about.
So put $match first, always:
// slow: groups the whole collection, then throws most of it away
[
{ $group: { _id: "$userId", total: { $sum: "$amount" } } },
{ $match: { _id: 123 } }
]
// fast: filters to one user with an index, then groups a tiny set
[
{ $match: { userId: 123 } },
{ $group: { _id: "$userId", total: { $sum: "$amount" } } }
]
Both return the same answer. The second one does a fraction of the work. Give that leading $match an index on userId and the pipeline never scans. Adding that index is creating an index; keeping the right set is index optimization.
Index the join, and mind the sort
Two more places aggregations quietly go slow:
A $lookup is a query per input document against the foreign collection. If the foreign field is not indexed, that is a collection scan repeated thousands of times. Index the foreignField, and the join goes from painful to boring.
A big $sort or $group is capped at 100MB of memory. Blow past it and the stage errors unless you pass allowDiskUse: true to spill to disk. Spilling works, but it is slow. The better fix is to sort on an indexed field early, so the sort rides the index instead of buffering the whole result set in memory.
Or let the optimizer read the plan for you
The reason people avoid aggregation plans is the same reason they avoid query plans: the output is dense, not missing. mongoui's Query Optimizer reads the aggregation's explain() and reports it in plain English, where the scan is, which stage is heavy, and the index that lets the pipeline start from a $match instead of the whole collection. Local narration makes no provider call. Explicit AI narration sends the authored pipeline and bounded structural plan context directly to your selected provider; full documents and MongoDB credentials stay local. This is the aggregation-shaped version of fixing a slow query: filter early, index the first stage, and let the pipeline work on the small set.
