BackendFebruary 10, 202512 min read

Using MongoDB Aggregations Effectively

MongoDB's aggregation pipeline is one of the most powerful and underused features in the ecosystem. Here's how to stop pulling data into JavaScript and start doing real work inside the database.

Using MongoDB Aggregations Effectively

I see the same pattern constantly in MERN codebases: fetch thousands of documents from MongoDB, load them into a JavaScript array, then filter, group, and sort them in application code. This works - until it doesn't. At scale, you're moving gigabytes of data across a network and burning server memory on logic the database could execute in milliseconds. The aggregation pipeline is MongoDB's answer to 'do the work where the data lives'.

# The Mental Model: A Pipeline of Stages

Think of aggregation as an assembly line. Documents enter the first stage, get transformed, pass to the next stage, get transformed again, and so on. Each stage outputs the input for the next. The order matters enormously - filtering early with `$match` means subsequent stages process fewer documents.

Always put `$match` as early as possible in your pipeline. It reduces the number of documents flowing through every subsequent stage, which is the single most impactful performance optimization in aggregation.

# The Core Stages You'll Use 90% of the Time

$match - Filter Documents

javascript
// Identical syntax to find() - use it to reduce your working set early
{ $match: { status: "completed", userId: ObjectId("64abc...") } }

$group - Aggregate Data

javascript
// Group orders by userId, compute total revenue and count
{
  $group: {
    _id: "$userId",           // group key
    totalRevenue: { $sum: "$amount" },
    orderCount:   { $sum: 1 },
    avgOrder:     { $avg: "$amount" },
    firstOrder:   { $min: "$createdAt" },
    lastOrder:    { $max: "$createdAt" },
  }
}

$project - Shape the Output

javascript
// Control exactly which fields appear in the output
{
  $project: {
    _id: 0,                                    // exclude _id
    userId: "$_id",                            // rename field
    totalRevenue: { $round: ["$totalRevenue", 2] },  // compute on output
    orderCount: 1,                             // include as-is
  }
}

# Real Example: E-Commerce Dashboard Analytics

javascript
// Get top 10 customers by revenue in the last 30 days
// with their average order value - all computed inside MongoDB

const topCustomers = await Order.aggregate([
  // Stage 1: Only completed orders from last 30 days
  {
    $match: {
      status: "completed",
      createdAt: {
        $gte: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000),
      },
    },
  },

  // Stage 2: Group by customer
  {
    $group: {
      _id: "$userId",
      totalSpent:  { $sum: "$amount" },
      orderCount:  { $sum: 1 },
      avgOrder:    { $avg: "$amount" },
    },
  },

  // Stage 3: Sort by highest spenders first
  { $sort: { totalSpent: -1 } },

  // Stage 4: Only top 10
  { $limit: 10 },

  // Stage 5: Join with users collection to get names
  {
    $lookup: {
      from:         "users",
      localField:   "_id",
      foreignField: "_id",
      as:           "userInfo",
    },
  },

  // Stage 6: Flatten the joined array
  { $unwind: "$userInfo" },

  // Stage 7: Shape the final output
  {
    $project: {
      _id: 0,
      name:       "$userInfo.name",
      email:      "$userInfo.email",
      totalSpent: { $round: ["$totalSpent", 2] },
      orderCount: 1,
      avgOrder:   { $round: ["$avgOrder", 2] },
    },
  },
]);

# $lookup: Joins in MongoDB

MongoDB is NoSQL, but sometimes you need data from multiple collections. `$lookup` performs left outer joins. Use it sparingly - if you find yourself joining five collections in every query, your schema design might need rethinking.

javascript
// Join orders with products to get full product details
{
  $lookup: {
    from:         "products",  // collection to join
    localField:   "productId", // field in current documents
    foreignField: "_id",       // field in the joined collection
    as:           "product",   // name for the joined array
  }
},

// $lookup produces an array - use $unwind to flatten it
{ $unwind: "$product" },

// Now you can access product.name, product.price, etc.

# $facet: Multiple Aggregations in One Query

`$facet` runs multiple sub-pipelines on the same input documents simultaneously. Perfect for search results pages where you need both the paginated results and the total count in one round trip.

javascript
// Single query returns paginated results AND total count AND category stats
const result = await Product.aggregate([
  { $match: { category: "electronics", price: { $lt: 500 } } },
  {
    $facet: {
      // Sub-pipeline 1: paginated results
      products: [
        { $sort: { rating: -1 } },
        { $skip: 0 },
        { $limit: 20 },
        { $project: { name: 1, price: 1, rating: 1, image: 1 } },
      ],

      // Sub-pipeline 2: total document count
      totalCount: [
        { $count: "count" },
      ],

      // Sub-pipeline 3: price range stats
      priceStats: [
        { $group: {
          _id: null,
          min: { $min: "$price" },
          max: { $max: "$price" },
          avg: { $avg: "$price" },
        }},
      ],
    },
  },
]);

const { products, totalCount, priceStats } = result[0];
💡

Use `explain('executionStats')` on your aggregation pipelines during development: `db.orders.explain('executionStats').aggregate([...])`. It shows which stages are slow, whether indexes are being used, and how many documents each stage processed. The most valuable debugging tool for aggregation performance.

Written by

Muhiu Din

Full-Stack Engineer

More Articles