Skip to content
ExponaBLOG
MONGODB, NOSQL & DATA MODELING · DAY 021 / 100

$facet for search pages: real-world lessons and examples

Results and filter counts in one query.

Expona Admin13 min read

Opening hook

Building search pages that show both results and category filters is one of the most common features in modern e-commerce and SaaS platforms. Traditionally, you'd run one query for the results and then run separate queries for each filter count—a pattern that wastes database resources and makes your API slower. MongoDB's $facet stage lets you run multiple aggregation pipelines against the same input document set in a single query, returning both your paginated results and all the filter counts in one round trip.

In this article, we'll walk through $facet for search pages: real-world lessons and examples, exploring how to structure faceted queries, handle edge cases, and avoid the pitfalls we've learned from production deployments.

Run several pipelines on the same input

The core strength of $facet is that it accepts an object where each key becomes a separate aggregation pipeline. All pipelines receive the same set of input documents—the results of any stages that come before the $facet stage. Each pipeline can then apply its own $match, $group, $sort, and $limit logic independently.

Let's say we're building a product search for an e-commerce site. We want to show:

  • A paginated list of matching products (10 per page).
  • Count of products by category.
  • Count of products by price range.
  • Count of products in stock vs. out of stock.

Without $facet, we'd need four separate database queries. With $facet, we get all this in a single round trip:

javascript
db.products.aggregate([
  {
    $match: {
      name: { $regex: 'laptop', $options: 'i' },
      status: 'active'
    }
  },
  {
    $facet: {
      // Pipeline 1: Paginated results
      results: [
        { $sort: { createdAt: -1 } },
        { $skip: 0 },
        { $limit: 10 }
      ],
      // Pipeline 2: Category counts
      categories: [
        { $group: { _id: '$category', count: { $sum: 1 } } },
        { $sort: { count: -1 } }
      ],
      // Pipeline 3: Price range counts
      priceRanges: [
        {
          $bucket: {
            groupBy: '$price',
            boundaries: [0, 500, 1000, 1500, 2000],
            default: 'other',
            output: { count: { $sum: 1 } }
          }
        }
      ],
      // Pipeline 4: Stock status counts
      stockStatus: [
        { $group: { _id: { $gt: ['$stock', 0] }, count: { $sum: 1 } } }
      ]
    }
  }
]).toArray()

The response structure mirrors your facet keys:

javascript
{
  results: [
    { _id: '...', name: 'Laptop Pro 15', price: 1499, ... },
    { _id: '...', name: 'Laptop Ultra', price: 1799, ... },
    // ... 10 documents
  ],
  categories: [
    { _id: 'Electronics', count: 42 },
    { _id: 'Computing', count: 38 }
  ],
  priceRanges: [
    { _id: 0, count: 5 },
    { _id: 500, count: 12 },
    { _id: 1000, count: 18 },
    { _id: 1500, count: 7 }
  ],
  stockStatus: [
    { _id: true, count: 35 },  // in stock
    { _id: false, count: 10 }  // out of stock
  ]
}

Each pipeline inside $facet operates independently after receiving the same filtered input. This is where the efficiency win comes from: you filter once, then branch out.

Return results plus category counts

The real-world use case that justifies $facet is the search results page with live filter counts. Imagine a user searches for "laptop" and expects to see 10 results, but also wants to know how many results exist in each price range, category, and brand—without leaving the page or making additional requests.

Here's a more realistic example from a travel booking platform:

javascript
db.hotels.aggregate([
  {
    $match: {
      city: 'New York',
      checkInDate: { $gte: ISODate('2025-03-01') },
      checkedOutDate: { $lte: ISODate('2025-03-07') },
      minRating: { $gte: 3.5 }
    }
  },
  {
    $facet: {
      // Results with pagination
      hotels: [
        { $sort: { rating: -1, pricePerNight: 1 } },
        { $skip: (pageNumber - 1) * pageSize },
        { $limit: 20 },
        {
          $project: {
            name: 1,
            rating: 1,
            pricePerNight: 1,
            availableRooms: 1,
            amenities: 1
          }
        }
      ],
      // Star rating distribution
      ratingDistribution: [
        {
          $group: {
            _id: { $floor: '$rating' },
            count: { $sum: 1 }
          }
        },
        { $sort: { _id: 1 } }
      ],
      // Price range distribution
      priceDistribution: [
        {
          $bucket: {
            groupBy: '$pricePerNight',
            boundaries: [0, 100, 200, 300, 500, 1000],
            default: 'Over $1000',
            output: { count: { $sum: 1 } }
          }
        }
      ],
      // Total matching count (for pagination UI)
      totalCount: [
        { $count: 'total' }
      ]
    }
  }
]).toArray()

The frontend receives everything it needs in one response. The UI can render the hotel cards, populate filter dropdowns with counts, and display pagination controls—all without additional backend calls. This pattern dramatically improves perceived performance and reduces server load.

The totalCount facet is especially useful: instead of running a separate count query, you get it alongside your results. Use it to calculate total pages for your pagination UI.

Great for e-commerce filters

E-commerce search is where $facet shines brightest. A typical product page needs to show filters for brand, color, size, price range, and availability—and the count next to each filter option tells the user how many products match that specific filter.

Consider a clothing retailer with products that have nested attributes:

javascript
db.products.aggregate([
  {
    $match: {
      category: 'Men\'s Shoes',
      archived: false,
      $or: [
        { brand: { $regex: 'Nike|Adidas', $options: 'i' } },
        { price: { $lte: 200 } }
      ]
    }
  },
  {
    $facet: {
      // Paginated product list
      items: [
        { $sort: { popularity: -1 } },
        { $skip: 0 },
        { $limit: 24 },
        {
          $project: {
            _id: 1,
            name: 1,
            price: 1,
            images: { $slice: ['$images', 1] },
            rating: 1
          }
        }
      ],
      // Filter: Brand
      brands: [
        { $group: { _id: '$brand', count: { $sum: 1 } } },
        { $sort: { count: -1 } },
        { $limit: 20 }
      ],
      // Filter: Size
      sizes: [
        { $unwind: '$sizes' },
        { $group: { _id: '$sizes', count: { $sum: 1 } } },
        { $sort: { _id: 1 } }
      ],
      // Filter: Color
      colors: [
        { $unwind: '$colors' },
        { $group: { _id: '$colors', count: { $sum: 1 } } },
        { $sort: { _id: 1 } }
      ],
      // Filter: Price ranges
      priceRanges: [
        {
          $bucket: {
            groupBy: '$price',
            boundaries: [0, 50, 100, 150, 200],
            default: 'Over $200',
            output: { count: { $sum: 1 } }
          }
        }
      ],
      // Total count for pagination
      meta: [
        { $count: 'total' }
      ]
    }
  }
]).toArray()

Notice the use of $unwind in the sizes and colors pipelines. If a product has multiple colors (stored as an array), $unwind creates one document per color before grouping. This gives you the true count of how many products are available in each color option.

The response structure maps directly to your filter UI:

javascript
{
  items: [ /* 24 products */ ],
  brands: [
    { _id: 'Nike', count: 45 },
    { _id: 'Adidas', count: 38 },
    { _id: 'New Balance', count: 12 }
  ],
  sizes: [
    { _id: '6', count: 20 },
    { _id: '7', count: 25 },
    { _id: '8', count: 30 }
  ],
  colors: [
    { _id: 'Black', count: 42 },
    { _id: 'White', count: 35 },
    { _id: 'Blue', count: 15 }
  ],
  priceRanges: [
    { _id: 0, count: 15 },
    { _id: 50, count: 28 },
    { _id: 100, count: 38 },
    { _id: 150, count: 22 }
  ],
  meta: [
    { total: 103 }
  ]
}

Your frontend iterates through these facets and renders checkboxes or radio buttons with counts. When the user clicks a filter, you add a new $match condition and re-run the faceted query. MongoDB's query cache helps keep this fast even with hundreds of filter combinations.

Watch the 16 MB result limit

This is the gotcha that catches many teams in production: MongoDB has a hard limit of 16 MB for a single aggregation pipeline result. With $facet, all facets count toward that same 16 MB limit.

If you're collecting extremely detailed facets on a large dataset, you can hit this ceiling quickly. For example:

javascript
db.logs.aggregate([
  { $match: { timestamp: { $gte: yesterday } } },
  {
    $facet: {
      // This could explode if you have thousands of unique IPs
      ips: [
        { $group: { _id: '$clientIp', count: { $sum: 1 } } }
        // Returns 50,000+ documents
      ],
      // This could also get huge
      paths: [
        { $group: { _id: '$requestPath', count: { $sum: 1 } } }
        // Returns 100,000+ documents
      ]
    }
  }
]).toArray()  // Error: aggregate result exceeds 16MB limit

Solutions to this problem:

  • Limit facet results: Use $limit in each facet pipeline. Return the top 20 brands, not all 5,000 if you only display that many on the UI.
  • Use allowDiskUse: Set allowDiskUse: true in the aggregation options. This tells MongoDB it can spill intermediate results to disk during processing, though it slows down the query.
  • Pre-compute facets: For very large or complex datasets, compute facets in a background job and cache the results in Redis. Query the cache for facets and only run the main facet query for results.
  • Break into separate queries: Sometimes it's better to run the results query and cache the facet results separately, depending on your data freshness requirements.

A practical approach for large e-commerce catalogs:

javascript
db.products.aggregate([
  { $match: { status: 'active' } },
  {
    $facet: {
      results: [
        { $sort: { popularity: -1 } },
        { $skip: 0 },
        { $limit: 24 }
      ],
      // Limit each facet to top N values
      brands: [
        { $group: { _id: '$brand', count: { $sum: 1 } } },
        { $sort: { count: -1 } },
        { $limit: 50 }  // Only show top 50 brands
      ],
      categories: [
        { $group: { _id: '$category', count: { $sum: 1 } } },
        { $limit: 30 }  // Only show top 30 categories
      ]
    }
  }
], { allowDiskUse: true }).toArray()

Common mistakes

Mistake 1: Running complex transformations in every facet. Each pipeline duplication multiplies CPU cost. If you need to join with other collections, do that before $facet, not inside it.

javascript
// Bad: $lookup happens 4 times
db.products.aggregate([
  { $match: { ... } },
  {
    $facet: {
      results: [{ $lookup: { ... } }, ...],
      categories: [{ $lookup: { ... } }, ...],
      brands: [{ $lookup: { ... } }, ...]
    }
  }
])

// Good: $lookup once, then facet
db.products.aggregate([
  { $match: { ... } },
  { $lookup: { ... } },
  {
    $facet: {
      results: [...],
      categories: [...],
      brands: [...]
    }
  }
])

Mistake 2: Not considering the filter UI when designing facets. If your frontend only shows the top 10 brands, don't return all 5,000. Add $limit to match your UI.

Mistake 3: Forgetting to handle empty results. If a facet returns no documents (e.g., no products in a certain price range), that facet key will be an empty array. Your frontend code needs to handle this gracefully.

Mistake 4: Using facets for real-time analytics on massive collections. $facet is great for search, but if you're running it against 100 million documents every single time a user loads a page, you'll see performance degradation. Consider pre-computing analytics or using $sample for approximate results.

Key takeaways

  • $facet runs multiple independent pipelines on the same input in a single query, dramatically reducing round trips to the database.
  • It's ideal for search results pages where you need both paginated results and filter counts simultaneously.
  • Always add $limit to facet pipelines that might return large result sets, and monitor your total response size against the 16 MB limit.
  • Do expensive operations (like $lookup) before $facet, not inside each facet, to avoid redundant work.
  • For very large datasets, consider hybrid approaches: cache frequently-accessed facets separately and reserve the full facet query for fresh, dynamic results.
  • Test your facet queries with realistic data volumes and filter combinations before deploying to production.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles