Why Your NestJS Aggregation Pipeline Is Slow (It's Not Your Indexes)
Most MongoDB aggregation slowdowns in NestJS come from stage order, not missing indexes. Here's how to fix $lookup memory spikes and force IXSCAN.
Why Your NestJS Aggregation Pipeline Is Slow (It's Not Your Indexes)
Add an index, watch the query speed up, move on. That's the instinct most of us have when a MongoDB aggregation starts crawling. It works right up until your orders collection crosses a few hundred thousand documents and the same pipeline that ran fine in staging starts throwing QueryExceededMemoryLimitNoDiskUseAllowed in production.
I've seen this exact failure play out in NestJS backends more times than indexing gaps alone. The real problem is usually stage order — where $lookup, $project, and $match sit relative to each other in the pipeline array.
The stage order is the index
MongoDB can only apply an index to an aggregation if the pipeline opens with $match (and, on the same compound prefix, $sort). Put a $lookup or $project first and you've thrown that away before the engine gets a chance to use it.
Here's the pattern that quietly wrecks performance:
// BAD: $lookup and $project execute before filtering, scanning the entire collection
const badPipeline = [
{ $lookup: { from: 'users', localField: 'userId', foreignField: '_id', as: 'user' } },
{ $project: { orderId: 1, total: 1, 'user.email': 1 } },
{ $match: { status: 'COMPLETED', createdAt: { $gte: startDate } } }, // scans everything first
];
The join and the projection both run against the full collection, and the filter only trims the result afterward. Flip the order and the same query becomes index-eligible:
// GOOD: filter using compound index { status: 1, createdAt: -1 } before touching anything else
const optimizedPipeline = [
{ $match: { status: 'COMPLETED', createdAt: { $gte: startDate } } },
{ $sort: { createdAt: -1 } },
{ $limit: 50 }, // shrink the working set before the expensive stage
{ $lookup: { from: 'users', localField: 'userId', foreignField: '_id', as: 'user' } },
{ $project: { orderId: 1, total: 1, user: { $arrayElemAt: ['$user.email', 0] } } },
];
Same result set, completely different execution cost. $limit before $lookup matters here too — you're capping how many documents the join even has to touch.
Stop letting $lookup scan the whole related collection
The default $lookup syntax joins on localField/foreignField and pulls back every matching document, unfiltered. On a transactions collection with millions of rows, that's expensive even with an index on the join key.
The fix is the correlated subquery form — let plus an inner pipeline — which lets you filter and project the joined collection before it comes back:
// src/orders/orders-aggregation.service.ts
import { Injectable } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose';
import { Model } from 'mongoose';
@Injectable()
export class OrdersAggregationService {
constructor(@InjectModel('Order') private readonly orderModel: Model<OrderDocument>) {}
async getCustomerHighValueAnalytics(organizationId: string) {
return await this.orderModel.aggregate([
// 1. Filter root collection using index: { orgId: 1, totalAmount: -1 }
{ $match: { orgId: organizationId, totalAmount: { $gt: 1000 } } },
// 2. Correlated subquery lookup with indexed inner filter
{
$lookup: {
from: 'transactions',
let: { orderId: '$_id' },
pipeline: [
// requires index on transactions: { orderId: 1, isSuccessful: 1 }
{
$match: {
$expr: {
$and: [
{ $eq: ['$orderId', '$$orderId'] },
{ $eq: ['$isSuccessful', true] },
],
},
},
},
{ $project: { transactionId: 1, paymentMethod: 1, settledAt: 1 } },
],
as: 'transactions',
},
},
// 3. Group and sort with a disk-spill safety net
{
$group: {
_id: '$customerId',
totalSpent: { $sum: '$totalAmount' },
orderCount: { $sum: 1 },
},
},
{ $sort: { totalSpent: -1 } },
{ $limit: 20 },
], {
allowDiskUse: true, // let $group spill to disk instead of throwing past 100MB
maxTimeMS: 5000, // fail fast instead of hanging a request thread
});
}
}
Most tutorials stop at the basic $lookup syntax and never mention the let/pipeline form, which is a shame — it's the difference between a join that scans everything and one that only ever touches matching rows. If you're joining against anything larger than a lookup table, this is worth the extra few lines every time.
Don't guess — check for IXSCAN
Don't assume an index is being used just because one exists on the collection. Run .explain() and check:
const executionPlan = await orderModel.aggregate(pipeline).explain('executionStats');
console.log(JSON.stringify(executionPlan.stages[0].$cursor.executionStats, null, 2));
Two things to look for in the output:
stage: "IXSCAN"— the query used an index.COLLSCANmeans it didn't, no matter how confident you were that it would.totalDocsExaminedclose tonReturned— if MongoDB examined 500,000 documents to return 50, your filter isn't as selective as you think, or it's running at the wrong stage.
I'd make this a habit on any aggregation touching a collection over ~50k documents, not just the ones that are already slow. It's cheap insurance.
Quick reference
| Anti-pattern | What breaks | Fix |
|---|---|---|
Late $match |
Full collection scan before filtering | Move $match to stage 1 |
Broad $lookup, no inner filter |
High RAM and network I/O on the join | Use let + pipeline with an indexed $match inside |
Unbounded $group |
Hits the 100MB memory ceiling | Filter early, and set allowDiskUse: true as a backstop |
$unwind on large arrays |
Document count multiplies, memory balloons | Prefer $map / $filter expressions over unwinding |
None of this requires exotic tooling — it's stage ordering, one alternate $lookup syntax, and the discipline to run .explain() instead of assuming. Get those three right and sub-25ms responses on multi-collection aggregations are a realistic target, not a lucky outcome.
Accelerate your Backend & Database Modernization Roadmap
Need custom architecture auditing, automated OpenAPI contract generation, or zero-downtime microservice migration guidance for your engineering team?
Frequently Asked Questions
Why does my MongoDB aggregation pipeline hit the 100MB memory limit?
Usually because $group or $sort runs on an unfiltered working set. MongoDB caps in-memory stage size at 100MB by default — filter with $match first, or set allowDiskUse to true so it can spill to disk.
Does $lookup use indexes in MongoDB?
Only if the foreignField is indexed and the lookup runs after your pipeline has already narrowed the document set with $match. An unindexed or unfiltered $lookup forces a collection scan for every input document.
Subscribe to RenovateAPI
Get weekly architectural guides, API refactoring strategies, and technical SEO updates delivered directly to your inbox.
Discussion (2)
Extremely helpful breakdown of the Strangler Fig pattern! We're currently refactoring a legacy Java monolith at work and the OpenAPI gateway routing tips saved us weeks of experimentation.
The schema JSON-LD and FAQ block structure really helps with indexing. Great technical detail on entity mentions too.
Suggested Related Articles
MongoDB Change Streams in NestJS Keep Dropping Events. Here's the Fix
Change Streams look production-ready in a demo and fall apart on deploy day. Here's how to persist resume tokens, batch under load, and survive replica set elections in NestJS.
Optimistic vs Pessimistic Locking in NestJS: Pick One, Not Both
A practical breakdown of optimistic versioning and SELECT FOR UPDATE in NestJS and PostgreSQL, with real code and a clear recommendation for wallets, inventory, and reservations.
Zero-Downtime PostgreSQL Migrations in NestJS: The Expand-and-Contract Playbook
How to run PostgreSQL schema changes in a NestJS app without locking production — concurrent index builds, the 4-phase column rename pattern, and lock_timeout guardrails.