Back to Insights
Databases & Storage•Optimizing Large-Scale MongoDB Aggregation Pipelines•deep dive•November 12, 2026•22 min read

Optimizing Large-Scale MongoDB Aggregation Pipelines: From Execution Plans to Production Tuning

Deep-dive into MongoDB aggregation pipeline internals: query planning, index utilization, operator optimization, sharding strategies, and production monitoring for petabyte-scale workloads.

T
Tamiz UddinFull-Stack Engineer

Aggregation pipelines are MongoDB's most powerful query mechanism — and its most performance-sensitive one. A pipeline that runs in 200ms against a million documents can take 45 minutes against a hundred million, with no algorithmic change, only scale. The difference between those two outcomes usually lives in execution plan selection, index coverage, operator ordering, and shard distribution — not in the pipeline logic itself.

This deep-dive examines the MongoDB query engine's approach to aggregation, identifies the specific mechanisms that cause exponential performance degradation, and provides the optimization strategies that matter at scale. We'll trace from wire protocol through the execution engine, then work through concrete transformations that reduce pipeline latency by orders of magnitude.

Table of Contents

1. The Aggregation Execution Engine

MongoDB's aggregation engine processes pipelines through a block-based execution model. Each $match, $group, $sort, $project, or other operator is compiled into an execution stage. These stages form a directed acyclic graph (DAG) when blocking operators like $group are present.

Blocking vs. Streaming Operators

The fundamental distinction in pipeline execution is between streaming and blocking operators:

javascript
// Streaming operators process documents one at a time (or in bounded batches)
// - $match: filters documents as they arrive
// - $project: transforms each document independently
// - $addFields: enriches each document
// - $sortByCount: wraps $group + $sort (blocking)

// Blocking operators must consume ALL input before producing output
// - $group: accumulates all matching documents into groups
// - $sort: must buffer enough documents to produce sorted output
// - $limit: buffers until enough documents are consumed
// - $skip: discards documents until offset is reached

The engine optimizes streaming pipelines aggressively — it can push $match stages down to the storage layer, enabling index-only scans. Blocking operators force materialization into memory or temporary files, which is where most large-scale pipeline failures originate.

Query Planning and Optimization Phases

When you submit an aggregation pipeline, MongoDB performs several optimization passes:

  1. Stage folding: Adjacent $project stages are merged into a single projection
  2. Match pushdown: Leading $match stages are converted to storage-level filters
  3. Sort optimization: $sort followed by $limit can use an index to avoid full sort
  4. Index selection: The planner evaluates candidate indexes for the first blocking or first stage

The planner's critical limitation: it optimizes the pipeline as a whole but cannot reorder operators across blocking boundaries. A $match that appears after a $group cannot be pushed below it, regardless of logical equivalence.

Wire Protocol and Batch Processing

The $collStats command and explain execution reveal that MongoDB processes aggregation in batches. For sharded clusters, the planning phase fans out to all shards, each shard plans independently, and results are merged at the mongos. This means:

  • Each shard may select different execution plans
  • Merge cost at mongos is proportional to the number of intermediate documents returned
  • Shard-local blocking operators duplicate memory pressure across all shards

2. Pipeline Operator Classification

Understanding which operators do what — and what they cost — is the foundation of optimization.

Cost Classification Matrix

OperatorBlocking?Memory Intensive?Index-Usable?Parallelizable?
$matchNoNoYes (leading)Yes
$groupYesYesNoYes (shard-local)
$sortYesYesYes (leading + limit)Partially
$projectNoNoNoYes
$addFieldsNoNoNoYes
$lookupNoYesYes (foreign key)Yes
$unwindNoNoYes (with $match)Yes
$facetYesYesMixedNo
$bucketYesYesNoPartially
$bucketAutoYesYesNoNo
$sortByCountYesYesYes (count field)Yes
$sampleYesNoYes (with $match)No
$out / $mergeYesNoNoNo
$unionWithNoNoDependsNo

The $group Accumulator Model

$group is the most common source of memory exhaustion. Each accumulator type has different memory characteristics:

javascript
// $sum: O(1) memory per group — accumulates into a single value
// $push: O(n) memory per group — stores all elements in an array
// $addToSet: O(k) memory per group where k = distinct values
// $first/$last: O(1) memory per group
// $avg: O(1) memory per group — maintains count + sum
// $min/$max: O(1) memory per group
// $stdDevPop/$stdDevSamp: O(1) memory per group — uses Welford's algorithm

// DANGEROUS AT SCALE:
{
  $group: {
    _id: "$customerId",  // If cardinality = 50M, this materializes 50M groups
    orders: { $push: "$orderData" },  // Each group holds ALL its orders in memory
    total: { $sum: "$amount" }
  }
}

The $push accumulator is the most dangerous at scale. A group with 10,000 documents each containing a 1KB nested object materializes 10MB for that single group. When group cardinality is high AND each group contains many documents, you hit the 100MB default memory limit immediately.

3. Index-Driven Optimization

Indexes are the single highest-impact optimization for aggregation pipelines. The rule: only the leading $match (before any blocking operator) benefits from index utilization.

Compound Index Design for Aggregation

javascript
// Pipeline:
// db.orders.aggregate([
//   { $match: { status: "completed", region: "us-east", createdAt: { $gte: new Date("2024-01-01") } } },
//   { $sort: { amount: -1 } },
//   { $limit: 100 },
//   { $group: { _id: "$customerId", total: { $sum: "$amount" } } }
// ])

// OPTIMAL INDEX (supports $match + $sort + projection):
db.orders.createIndex(
  { status: 1, region: 1, createdAt: -1, amount: -1 },
  { name: "agg_status_region_date_amount" }
)

// This index allows:
// 1. Index range scan for status + region (equality)
// 2. Sorted traversal by createdAt (range)
// 3. Sorted traversal by amount within the range
// 4. Covering projection if all needed fields are in the index

Index-Only (Covering) Scans

A covering index eliminates collection document fetches entirely. The pipeline reads exclusively from the index B-tree:

javascript
// If the pipeline only needs fields present in the index:
db.orders.aggregate([
  { $match: { status: "completed", region: "us-east" } },
  { $group: { _id: "$customerId", total: { $sum: "$amount" } } },
  { $project: { _id: 1, total: 1 } }
]).explain("executionStats")

// Required covering index:
db.orders.createIndex(
  { status: 1, region: 1, customerId: 1, amount: 1 },
  { name: "agg_covering_status_region_cust_amount" }
)

// Verify with explain — look for "IXSCAN" with no "FETCH" stage

The executionStats output reveals whether the pipeline achieves an index-only scan. If you see stage: "FETCH" in the plan, the engine is reading full documents from the collection. Eliminating this stage can reduce I/O by 10-100x depending on document size.

Multikey Indexes and Array Fields

When grouping on array fields, MongoDB creates multikey indexes that expand the index entries:

javascript
// Document structure:
// { orderId: 123, tags: ["electronics", "premium", "sale"], amount: 99.99 }

// Multikey index on array field:
db.orders.createIndex({ tags: 1, amount: 1 })

// WARNING: Multikey indexes prevent index-only scans for $group.
// The engine cannot determine which index entry maps to which document
// without fetching the full document. This forces FETCH stage.

// Better approach: pre-compute a non-array grouping field
// or use $unwind + $match before $group

Partial Indexes for Aggregation

Partial indexes reduce index size and improve cache residency:

javascript
// Only index documents relevant to the aggregation
db.orders.createIndex(
  { customerId: 1, amount: 1 },
  {
    name: "agg_active_orders",
    partialFilterExpression: {
      status: { $in: ["completed", "refunded"] },
      amount: { $gt: 0 }
    }
  }
)

// Benefit: Smaller index = better B-tree depth = fewer disk pages in cache
// A 500MB index vs a 5GB index can be the difference between
// entirely in-memory scan vs repeated disk I/O

4. Operator Ordering and Materialization

The order of operators in a pipeline determines memory pressure, disk spilling, and execution time. The optimization principle: filter early, transform late.

The Filtering Cascade Pattern

javascript
// ANTI-PATTERN: Group before filtering
// Materializes ALL groups, then discards most
[
  { $group: { _id: "$customerId", total: { $sum: "$amount" } } },
  { $match: { total: { $gte: 1000 } } },  // Too late — all groups already materialized
  { $sort: { total: -1 } },
  { $limit: 1000 }
]

// OPTIMIZED: Push filtering before grouping where possible
[
  { $match: { amount: { $gte: 1 } } },  // Pre-filter cheaply (uses index)
  { $group: { _id: "$customerId", total: { $sum: "$amount" } } },
  { $match: { total: { $gte: 1000 } } },  // Post-group filter (unavoidable)
  { $sort: { total: -1 } },
  { $limit: 1000 }
]

The $sort + $limit Optimization

$sort followed by $limit is a special pattern that MongoDB optimizes. Instead of sorting ALL documents, it maintains a bounded priority queue of size N (the limit). This is O(n log k) where k = limit:

javascript
// Pipeline: Get top 100 customers by order count in a specific category
// WITHOUT $sort+$limit: sorts ALL matching documents into memory
[
  { $match: { category: "electronics" } },
  { $group: { _id: "$customerId", count: { $sum: 1 } } },
  { $sort: { count: -1 } },  // Sorts ALL groups — potentially millions
  { $limit: 100 }
]

// The engine DOES optimize $sort + $limit into a top-N heap,
// but you can reduce input size further with pre-filtering:
[
  { $match: { category: "electronics", orderDate: { $gte: last30Days } } },
  { $group: { _id: "$customerId", count: { $sum: 1 } } },
  { $sort: { count: -1 } },
  { $limit: 100 }
]

$facet: Isolation at a Cost

$facet allows parallel sub-pipelines within a single document. It's powerful but forces materialization of ALL input into memory:

javascript
// $facet materializes ALL input documents before processing
// Only use when the input to $facet is already heavily filtered
[
  { $match: { region: "us-east", status: "completed" } },  // Heavy pre-filter
  { $facet: {
    revenueByMonth: [
      { $group: { _id: { $month: "$createdAt" }, revenue: { $sum: "$amount" } } },
      { $sort: { _id: 1 } }
    ],
    topProducts: [
      { $group: { _id: "$productId", count: { $sum: 1 } } },
      { $sort: { count: -1 } },
      { $limit: 10 }
    ],
    summary: [
      { $group: { _id: null, totalRevenue: { $sum: "$amount" }, orderCount: { $sum: 1 } } }
    ]
  }}
]

// Memory cost: ALL documents matching $match are held in memory simultaneously
// for ALL sub-pipelines. For 1M documents × 500 bytes = 500MB just for input buffering.

allowDiskUse: When and How

When materialization exceeds the 100MB in-memory limit, MongoDB can spill to disk:

javascript
// CRITICAL: allowDiskUse is NOT always safe
// It trades memory for disk I/O — potentially making things 10-100x slower

// Safe with allowDiskUse: predictable, bounded output
// - $group with known cardinality
// - $sort with known input size

// DANGEROUS with allowDiskUse: unpredictable output size
// - $push with unknown group sizes
// - $facet with multiple sub-pipelines on large input
// - $sort on high-cardinality data with no $limit

// Production pattern: set allowDiskUse with monitoring
[
  { $match: { status: "completed" } },
  { $group: { _id: "$region", revenue: { $sum: "$amount" } } },
  { $sort: { revenue: -1 } }
], { allowDiskUse: true }

5. $lookup and Join Optimization

$lookup is MongoDB's equi-join operator. At scale, it behaves like a hash join and can consume enormous memory.

$lookup Execution Models

javascript
// Basic $lookup (hash join on foreign key)
db.orders.aggregate([
  {
    $lookup: {
      from: "customers",
      localField: "customerId",
      foreignField: "_id",
      as: "customer"
    }
  }
])

// The engine builds a hash index on the foreign collection's foreignField.
// For 50M customer documents, this hash index consumes ~2-4GB of RAM.
// This is the #1 cause of OOM in aggregation pipelines.

Optimized $lookup Patterns

javascript
// PATTERN 1: Filter before lookup (reduce hash table size)
[
  { $match: { status: "completed" } },  // Reduce input documents
  {
    $lookup: {
      from: "customers",
      localField: "customerId",
      foreignField: "_id",
      as: "customer",
      // pipeline option allows filtering the foreign collection BEFORE hash build
      pipeline: [
        { $match: { active: true, region: "us-east" } },
        { $project: { name: 1, tier: 1 } }  // Project only needed fields
      ]
    }
  }
]

// PATTERN 2: Replace $lookup with pre-joined embedded data
// If the lookup field is rarely updated, embed it at write time:
// { orderId: 123, customerId: "c1", customerName: "Alice", customerTier: "gold" }
// Eliminates the join entirely — read-only, zero join cost

// PATTERN 3: Use $expr with $in for single-document lookups
// When you only need to verify existence:
{
  $match: {
    $expr: {
      $eq: ["$customerId", "$customerLookup"]  // Pre-resolved
    }
  }
}

Hash Join Memory Analysis

javascript
// Memory consumed by $lookup hash table:
// Formula: numDocuments × (keySize + valueReferenceSize + overhead)
// For 50M customers with ObjectId keys:
//   = 50M × (12 bytes ObjectId + 8 bytes pointer + ~32 bytes overhead)
//   = 50M × 52 bytes ≈ 2.6 GB

// Mitigation: Use the pipeline option to reduce foreign collection size
// BEFORE hash table construction:
db.orders.aggregate([
  {
    $lookup: {
      from: "customers",
      localField: "customerId",
      foreignField: "_id",
      as: "customer",
      pipeline: [
        // This $match runs on the foreign collection BEFORE hash build
        { $match: { _id: { $in: topCustomerIds } } },  // If possible
        { $project: { name: 1, email: 1 } }  // Slim the documents
      ]
    }
  }
])

// Even better: If you know the set of customerIds in advance,
// pre-fetch customer data and use $addFields with $reduce/$arrayElemAt

6. Sharding-Aware Pipeline Design

In a sharded cluster, aggregation pipelines execute as a distributed plan. Understanding the fan-out and merge phases is essential for optimization.

Distributed Execution Model

sql
┌─────────────────────────────────────────────────────────────┐
│                        mongos                                │
│  ┌───────────────────────────────────────────────────────┐  │
│  │              Global Planning Phase                     │  │
│  │  1. Send pipeline to all shards for planning          │  │
│  │  2. Collect execution plans from each shard          │  │
│  │  3. Select merge strategy based on operator type      │  │
│  └───────────────────────────────────────────────────────┘  │
│  ┌───────────────────────────────────────────────────────┐  │
│  │              Fan-Out Execution Phase                   │  │
│  │  Send pipeline to Shard 1 → returns partial results    │  │
│  │  Send pipeline to Shard 2 → returns partial results    │  │
│  │  Send pipeline to Shard N → returns partial results    │  │
│  └───────────────────────────────────────────────────────┘  │
│  ┌───────────────────────────────────────────────────────┐  │
│  │              Merge Phase (at mongos)                   │  │
│  │  Merge all partial results into final output            │  │
│  └───────────────────────────────────────────────────────┘  │
└─────────────────────────────────────────────────────────────┘

Shard Key Alignment

The shard key determines whether a pipeline can execute entirely on a single shard (no fan-out):

javascript
// Sharded collection: orders
// Shard key: { customerId: "hashed" }

// Pipeline 1: SINGLE SHARD (no fan-out)
// $match on shard key routes to exactly one shard
db.orders.aggregate([
  { $match: { customerId: "customer_12345" } },  // Routes to 1 shard
  { $group: { _id: "$category", total: { $sum: "$amount" } } }
])

// Pipeline 2: FULL FAN-OUT (all shards)
// No $match on shard key before first blocking operator
db.orders.aggregate([
  { $group: { _id: "$category", total: { $sum: "$amount" } } },  // ALL shards
  { $sort: { total: -1 } },
  { $limit: 10 }
])

// Pipeline 3: PARTIAL FAN-OUT
// $match on non-shard-key field fans out but reduces per-shard work
db.orders.aggregate([
  { $match: { status: "completed" } },  // All shards, but less data per shard
  { $group: { _id: "$region", total: { $sum: "$amount" } } },
  { $sort: { total: -1 } }
])

Merge Strategy by Operator

Different operators merge differently at mongos:

OperatorMerge StrategyCost
$group + $sum/$avgTwo-phase: shard-local partial groups → global re-groupO(shards × groups)
$group + $push/$addToSetTwo-phase: shard-local arrays → global concat + dedupO(shards × groups × avgArraySize)
$sort + $limitPartial sort on each shard → merge top-N at mongosO(shards × log(limit))
$sort (no limit)Partial sort on each shard → merge all sorted streamsO(totalDocuments)
$countSum of shard countsO(shards)
$first/$lastCannot merge correctly across shards without global sortRequires special handling

Avoiding Two-Phase $group

The two-phase group merge (partial groups on shards, then global re-group) doubles the grouping work. You can avoid it:

javascript
// Two-phase group (default when group _id is not the shard key):
db.orders.aggregate([
  { $group: { _id: "$category", total: { $sum: "$amount" } } }
  // Phase 1: Each shard groups by category → returns partial results
  // Phase 2: mongos re-groups all partial results
  // If 10 shards × 50 categories = 500 intermediate docs to merge
])

// Single-phase group (when group _id IS the shard key):
db.orders.aggregate([
  { $match: { customerId: { $in: allCustomerIds } } },  // Routes to specific shards
  { $group: { _id: "$customerId", total: { $sum: "$amount" } } }
  // Each shard handles its own customerId groups — no merge needed
])

// Alternative: Pre-aggregate in application layer
// Issue N separate queries (one per shard) and merge results in app code
// Gives you explicit control over merge logic and error handling

Zoned Sharding for Time-Series Aggregations

javascript
// For time-series data, zoned sharding keeps date ranges on single shards
db.adminCommand({
  shardCollection: "analytics.events",
  uniqueIndex: {
    key: { timestamp: 1, service: 1 },
    partialFilterExpression: { timestamp: { $type: "date" } },
    shardKeyPattern: {
      timestamp: { $gte: new Date("2024-01-01"), $lt: new Date("2024-02-01") }
    }
  }
})

// Now: monthly aggregations hit a SINGLE shard
[
  { $match: { timestamp: { $gte: new Date("2024-01-01"), $lt: new Date("2024-02-01") } } },
  { $group: { _id: "$service", count: { $sum: 1 } } }
]
// No fan-out, no merge — single shard executes the entire pipeline

7. Execution Plan Analysis

The explain command is the primary diagnostic tool for pipeline optimization.

Reading Execution Plans

javascript
const plan = db.orders.aggregate([
  { $match: { status: "completed", createdAt: { $gte: new Date("2024-01-01") } } },
  { $group: { _id: "$region", revenue: { $sum: "$amount" } } },
  { $sort: { revenue: -1 } }
]).explain("executionStats")

// Key metrics to examine:
console.log(plan.stages)  // Execution stage tree
console.log(plan.executionStats.totalDocsExamined)  // Documents read
console.log(plan.executionStats.totalKeysExamined)  // Index entries scanned
console.log(plan.executionStats.allPlansExecution[0].stage)  // Selected stage type
console.log(plan.executionStats.executionTimeMillis)  // Wall clock time

Critical Metrics

javascript
// The ratio of docsExamined to nReturned tells you pipeline efficiency:
// docsExamined / nReturned = 1.0 → perfect (index-only, no waste)
// docsExamined / nReturned = 1000 → reading 1000 docs to produce 1 result

// For aggregation pipelines:
const stats = plan.executionStats
const efficiency = stats.totalDocsExamined / Math.max(stats.nReturned, 1)

if (efficiency > 10) {
  console.log(`WARNING: Examining ${stats.totalDocsExamined} docs for ${stats.nReturned} results`)
  console.log("Consider: better index, earlier $match, or covering index")
}

// Check for COLLSCAN (full collection scan — almost always bad):
function hasCollectionScan(stages) {
  for (const stage of stages) {
    if (stage.stage === "COLLSCAN") return true
    if (stage.inputStages && hasCollectionScan(stage.inputStages)) return true
  }
  return false
}

if (hasCollectionScan(plan.stages)) {
  console.log("CRITICAL: Pipeline performs full collection scan")
}

// Check for blocking stages that could be eliminated:
function findBlockingStages(stages) {
  const blocking = new Set(["SORT", "GROUP_BY", "COUNT", "UNION_WITH" ])
  const results = []
  for (const stage of stages) {
    if (blocking.has(stage.stage)) results.push(stage)
    if (stage.inputStages) results.push(...findBlockingStages(stage.inputStages))
  }
  return results
}

The $planCache and plan stability

MongoDB caches execution plans in the plan cache. For aggregation, the cache key includes the pipeline's first operator and its arguments:

javascript
// Clear the plan cache for a collection (use sparingly in production):
db.orders.runCommand({ planCacheClear: "orders" })

// Lock a specific plan (MongoDB 5.0+):
db.orders.runCommand({
  planCacheSet: "orders",
  filters: [{
    type: "aggsPlan",
    query: { status: "completed" },
    indexName: "agg_status_region_date_amount"
  }],
  params: "locked"
})

// This prevents the planner from choosing a suboptimal plan
// after cache expiration or workload shift

8. Data Modeling for Aggregation

Sometimes the optimal optimization is not in the pipeline but in the data model.

Pre-Aggregation Pattern

javascript
// Instead of aggregating 10M order documents on every query,
// maintain a pre-aggregated collection updated via triggers or scheduled jobs:

// orders_summary collection (updated every 5 minutes or via change streams):
// {
//   _id: { customerId: "c123", month: "2024-01" },
//   totalRevenue: 5000.00,
//   orderCount: 25,
//   avgOrderValue: 200.00,
//   lastOrderAt: ISODate("2024-01-28")
// }

// Query: instant read from pre-aggregated data
db.orders_summary.aggregate([
  { $match: { "_id.customerId": "c123" } },
  { $sort: { "_id.month": -1 } },
  { $limit: 12 }
])

// Update via change streams:
const changeStream = db.orders.watch([
  { $match: { "operationType": { $in: ["insert", "update", "delete"] } } }
])

changeStream.on("change", async (change) => {
  // Incrementally update orders_summary
  // This trades write amplification for read performance
})

Denormalization for Aggregation

javascript
// ANTI-PATTERN: Join-heavy aggregation
db.orders.aggregate([
  { $lookup: { from: "customers", localField: "customerId", foreignField: "_id", as: "cust" } },
  { $unwind: "$cust" },
  { $lookup: { from: "products", localField: "productId", foreignField: "_id", as: "prod" } },
  { $unwind: "$prod" },
  { $group: { _id: "$cust.region", revenue: { $sum: "$amount" } } }
])
// Cost: 2 hash joins, potentially GB of memory

// OPTIMIZED: Denormalize frequently-joined fields
// Order document already contains:
// { orderId: 123, customerId: "c1", customerRegion: "us-east", productId: "p1", productName: "Widget", amount: 99.99 }

db.orders.aggregate([
  { $match: { customerRegion: "us-east" } },
  { $group: { _id: "$customerRegion", revenue: { $sum: "$amount" } } }
])
// Cost: Single index scan, no joins

Rollup Hierarchies

For OLAP-style queries, maintain multiple rollup levels:

javascript
// orders_by_day: 1 document per (customer, day) — updated continuously
// orders_by_week: 1 document per (customer, week) — updated daily
// orders_by_month: 1 document per (customer, month) — updated monthly

// Query strategy: route based on time range
db.orders_by_month.aggregate([
  { $match: { "_id.customerId": "c123", "_id.month": { $gte: "2023-01", $lte: "2024-12" } } },
  { $sort: { "_id.month": -1 } }
])
// Returns 24 documents instead of aggregating 500,000 order documents

9. Production Monitoring and Alerting

Monitoring aggregation pipeline performance requires instrumentation at multiple levels.

Slow Query Log Configuration

javascript
// Enable slow query logging for aggregation operations
db.adminCommand({
  profile: 2,  // Log ALL operations
  slowms: 100  // Threshold: 100ms
})

// In production, use threshold-based:
db.adminCommand({
  profile: 1,
  slowms: 2000  // Only log operations > 2 seconds
})

// Query the system.profile collection:
db.system.profile.aggregate([
  { $match: { op: "command", "command.aggregate": { $exists: true }, duration: { $gt: 5000 } } },
  { $sort: { duration: -1 } },
  { $limit: 20 }
])

Programmatic Pipeline Performance Monitoring

javascript
// Wrapper that measures and logs aggregation performance
async function monitoredAggregate(collection, pipeline, options = {}) {
  const { timeout = 30000, alertThreshold = 5000, label = "pipeline" } = options
  
  const startTime = process.hrtime()
  const controller = new AbortController()
  const timeoutId = setTimeout(() => controller.abort(), timeout)
  
  try {
    // Get execution stats
    const explainResult = await collection
      .aggregate(pipeline, { ...options, explain: "executionStats" })
      .toArray()
    
    const stats = explainResult[0]
    const execStats = stats.executionStats || stats.stages?.[0]?.executionStats
    
    const metrics = {
      label,
      executionTimeMs: execStats?.executionTimeMillis,
      docsExamined: execStats?.totalDocsExamined,
      keysExamined: execStats?.totalKeysExamined,
      nReturned: execStats?.nReturned,
      hasCollectionScan: JSON.stringify(stats.stages).includes('COLLSCAN'),
      memoryBytes: execStats?.memUsage
    }
    
    // Alert on degradation
    if (metrics.executionTimeMs > alertThreshold) {
      console.error(`[ALERT] Pipeline "${label}" exceeded threshold:`, metrics)
    }
    
    return metrics
  } finally {
    clearTimeout(timeoutId)
  }
}

// Usage:
const stats = await monitoredAggregate(
  db.orders,
  [
    { $match: { status: "completed" } },
    { $group: { _id: "$region", revenue: { $sum: "$amount" } } }
  ],
  { label: "revenue_by_region", alertThreshold: 3000 }
)

Server Status Metrics for Aggregation

javascript
// Current in-progress operations (detects stuck pipelines)
db.adminCommand({ currentOp: true, allOps: false }).inprog
  .filter(op => op.cmd?.aggregate)

// Detect operations exceeding threshold:
const slowOps = db.adminCommand({ currentOp: true, allOps: false }).inprog
  .filter(op => {
    if (!op.cmd?.aggregate) return false
    const elapsed = Date.now() - op.activeSince?.getTime()
    return elapsed > 60000  // Running for over 60 seconds
  })

// Kill a stuck aggregation:
db.adminCommand({ killCursors: "orders", cursors: [cursorId] })
// Or kill by operation:
db.adminCommand({ killOp: opId })

// Cluster-wide aggregation monitoring:
db.adminCommand({ serverStatus: 1 }).metrics.document
// {
//   insert: ...,  // document inserts
//   query: ...,   // document queries  
//   update: ...,  // document updates
//   delete: ...   // document deletes
// }

db.adminCommand({ serverStatus: 1 }).metrics.aggregate
// {
//   scans: ...,        // documents scanned by aggregation
//   resultSets: ...,  // documents returned by aggregation
//   yields: ...        // context switches during aggregation
// }

Alerting Rules

javascript
// Example: Alert when aggregation scan ratio degrades
// scans / resultSets > 100 indicates inefficient pipeline
const aggregateMetrics = db.adminCommand({ serverStatus: 1 }).metrics.aggregate
const scanRatio = aggregateMetrics.scans / Math.max(aggregateMetrics.resultSets, 1)

if (scanRatio > 100) {
  // ALERT: Aggregation pipelines are scanning 100x more documents than returned
  // Action: Review recent pipeline changes, check for index drops
}

// Alert when ops control detects high aggregation load
const activeAggregations = db.adminCommand({ currentOp: true }).inprog
  .filter(op => op.cmd?.aggregate).length

if (activeAggregations > 10) {
  // ALERT: More than 10 concurrent aggregation operations
  // May indicate application bug or thundering herd
}

10. Comprehensive Optimization Checklist

Pre-Deployment Validation

  1. Index existence: Verify all leading $match fields have supporting indexes
  2. Index coverage: Confirm the index covers all projected fields (eliminate FETCH stage)
  3. Cardinality analysis: Measure $group _id cardinality — if > 10M, consider pre-aggregation
  4. Shard routing: Validate whether pipeline fans out or routes to single shard
  5. Memory estimation: Calculate worst-case memory for blocking operators
  6. explain validation: Run explain("executionStats") on representative data

Runtime Optimization

  1. Leading $match: Always place $match before blocking operators
  2. Covering indexes: Design indexes to eliminate collection document fetches
  3. $sort + $limit: Always pair expensive sorts with limits
  4. $lookup pipeline option: Filter foreign collection before hash build
  5. allowDiskUse monitoring: Enable but alert on disk spill events
  6. Plan cache locking: Lock optimal plans for critical pipelines

Architecture-Level Decisions

  1. Pre-aggregation: For sub-second latency requirements, maintain rollup collections
  2. Denormalization: Embed join fields for read-heavy workloads
  3. Shard key alignment: Design shard keys to match query patterns
  4. Read preferences: Route analytics queries to secondary nodes
  5. Query isolation: Use dedicated replica set members for heavy aggregations
  6. Connection pooling: Limit concurrent aggregation operations per node

Ongoing Maintenance

  1. Index rebuild schedule: Rebuild indexes after mass data ingestion
  2. Plan cache monitoring: Alert when plans change unexpectedly
  3. Workload analysis: Regularly review system.profile for regression
  4. Capacity planning: Model growth rates against memory limits
  5. Kill switch: Implement timeout-based circuit breakers for aggregation operations

The optimization landscape for MongoDB aggregation pipelines operates at the intersection of query planning, memory management, distributed execution, and data modeling. There is no single silver bullet — effective optimization requires understanding how the engine plans your specific pipeline, which operators force materialization, and how shard distribution affects the merge phase. The patterns above, applied systematically, routinely reduce pipeline latency from minutes to milliseconds at scale. For teams operating at petabyte scale, invest in execution plan monitoring and automated alerting as infrastructure — pipeline performance degradation is often the leading indicator of broader system issues.

For deeper exploration of query optimization patterns across database systems, see Tamiz's Insights for related deep-dives on distributed systems performance.

Frequently Asked Questions

Why does my $group operator consume all available memory?

$group materializes one entry per distinct _id value in memory. If your grouping field has high cardinality (millions of distinct values), each entry's memory footprint accumulates. The fix is one of: (1) reduce cardinality by pre-filtering with $match before $group, (2) replace $push with bounded accumulators like $sum/$avg that use O(1) memory per group, (3) enable allowDiskUse: true to spill overflow to disk, or (4) redesign the query to avoid grouping on a high-cardinality field entirely.

How do I determine if my aggregation pipeline is using an index?

Run pipeline.explain("executionStats") and inspect the stage tree. Look for IXSCAN stages — these indicate index usage. If you see COLLSCAN, the pipeline is scanning the entire collection. The specific index used appears in the IXSCAN stage's pattern field. Additionally, check executionStats.totalKeysExamined — if it's significantly lower than totalDocsExamined, you're getting index benefits.

When should I use $facet vs. separate aggregation pipelines?

Use $facet when you need multiple aggregations over the SAME filtered dataset and want to avoid scanning the collection multiple times. However, $facet materializes ALL input documents in memory simultaneously for all sub-pipelines. If the input is already small (post-filter), $facet is ideal. If the input is large, prefer separate pipelines with individual $match stages — each can use its own optimal index and avoids holding all documents in memory at once.