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:
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:
{
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:
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:
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:
{
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:
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
$limitin 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: truein 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:
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.
// 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
$facetruns 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
$limitto 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.






Comments
Loading comments…