kept fast inside a Hyvä theme
Slow bucket aggregations on the category page often cost more time than the actual rendering in Hyvä shops. Tuning facet count, the GraphQL query, and caching together brings layered navigation back to a speed that matches the rest of the frontend.
Table of Contents
- 1. Why facet aggregations slow down the category page
- 2. How Magento builds layered navigation aggregations under the hood
- 3. Capping facet count and ordering in attribute administration
- 4. Keeping the layered navigation GraphQL query deliberately lean
- 5. A caching strategy for facet results in the Hyvä frontend
- 6. Hyvä implementation: Alpine loading states and debouncing filter changes
- 7. Monitoring: using the slow log and the profile API to find expensive aggregations
- 8. A practical example: measurable improvement after targeted reduction
- 9. Checklist: keeping facet performance under control long term
- 10. Summary
- 11. FAQ
1. Why facet aggregations slow down the category page
Every terms aggregation that Elasticsearch or OpenSearch computes for a filterable attribute recounts hits for every single attribute value within the current product set. For an attribute like color with two hundred values on a category with several thousand products, that adds up fast to an expensive operation that gets re-run on every single page view, regardless of whether the underlying data changed at all since the last request.
In a Hyvä theme, layered navigation runs through a GraphQL query that typically fetches the product list and the aggregations in a single request. If the aggregation is slow inside the search cluster, that directly delays the time to first byte for the whole category page, because the resolver waits for the full response before Magento can even start rendering. That coupling turns facet performance into a frontend problem, even though the real cause sits deep inside the search cluster.
2. How Magento builds layered navigation aggregations under the hood
The aggregation builder in Magento\Elasticsearch\SearchAdapter\Aggregation\Builder creates a separate terms aggregation for every attribute marked as filterable within the same search request. Without an explicit limit, the cluster returns a fairly large number of buckets per attribute by default, including values that only have one or two hits in the current result set and are practically never relevant to a customer.
Since Magento 2.4.7, the same aggregation logic can run against OpenSearch instead of Elasticsearch through a compatibility layer that leaves the query DSL largely unchanged. Differences show up mainly in circuit breaker behavior and in the default values for max_result_window, so the same category with many filter attributes can behave differently in speed or error rate depending on which cluster type it runs against.
query LayeredNavigation($categoryId: String!) {
products(filter: { category_id: { eq: $categoryId } }) {
total_count
aggregations {
attribute_code
label
count
options {
label
value
count
}
}
}
}
3. Capping facet count and ordering in attribute administration
The most effective lever is often not code but attribute management: every attribute set to Use in Layered Navigation under Stores > Attributes > Product adds another aggregation to every category call. An honest review of which attributes customers actually use for filtering, versus which ones were only enabled historically, often cuts the number of parallel aggregations in half without customers noticing any difference.
For the remaining attributes, a plugin that caps the number of returned options per facet server-side and sorts them by hit count, instead of leaving the cluster to return the full unsorted bucket list, pays off further. Customers see the most relevant values first, while the cluster has to return less data over the wire at the same time.
<?php
declare(strict_types=1);
namespace Mironsoft\SearchOptimize\Plugin;
use Magento\Elasticsearch\SearchAdapter\Aggregation\Builder\Bucket\TermBucketBuilder;
/**
* Caps the number of bucket options per terms aggregation to noticeably
* relieve facets that carry a large number of attribute values.
*/
class LimitFacetBucketSizePlugin
{
private const MAX_BUCKET_SIZE = 20;
/**
* Sets the maximum bucket size before the aggregation is built.
*
* @param TermBucketBuilder $subject
* @param array $bucketConfig
* @param array $context
* @return array
*/
public function beforeBuild(TermBucketBuilder $subject, array $bucketConfig, array $context): array
{
$bucketConfig['size'] = self::MAX_BUCKET_SIZE;
return [$bucketConfig, $context];
}
}
4. Keeping the layered navigation GraphQL query deliberately lean
Many Hyvä implementations fetch the product list and the aggregations in exactly the same query, regardless of whether the user is just toggling a filter or navigating to a new page. On a pure filter change, the product list does change too, but fields for images, description, or variants are not re-rendered at that moment at all, because Alpine only swaps out the facet area. A leaner, dedicated query for the filter case reduces both the resolver work on the Magento side and the response size.
The @include directive additionally controls whether price range buckets, which are expensive to compute server-side, get shipped at all. If the price filter widget is not even visible in the current viewport, for instance because it sits behind a collapsed accordion, the variable can be set to false client-side, and the cluster skips the histogram computation entirely.
query FacetsOnly($categoryId: String!, $withPriceBuckets: Boolean!) {
products(filter: { category_id: { eq: $categoryId } }) {
total_count
aggregations {
attribute_code
count
options {
label
value
count
}
}
price_range @include(if: $withPriceBuckets) {
minimum_price {
regular_price { value }
}
}
}
}
5. A caching strategy for facet results in the Hyvä frontend
The Full Page Cache reliably covers only the unfiltered category page, because every filter combination produces its own GraphQL request with its own variables, and that request usually is not cacheable at all as a POST call. In practice this means that as soon as a customer applies a filter, the request hits the search cluster unthrottled, even if the exact same combination was already requested by another customer seconds earlier.
A Redis-backed cache at resolver level that holds aggregation results for a few minutes, keyed by a hash of category id and sorted filter parameters, noticeably relieves the cluster without customers ever seeing stale results. The simplest way to invalidate it is to hook into the same events that already refresh the search index, such as saving a product or reconciling stock, so no extra invalidation logic needs to be maintained.
<?php
declare(strict_types=1);
namespace Mironsoft\SearchOptimize\Model;
use Magento\Framework\App\CacheInterface;
use Magento\Framework\Serialize\SerializerInterface;
/**
* Caches aggregation results per category/filter combination for a short
* TTL to avoid repeating expensive terms aggregations.
*/
class FacetResultCache
{
private const CACHE_TAG = 'mironsoft_facet_result';
private const TTL_SECONDS = 300;
/**
* @param CacheInterface $cache
* @param SerializerInterface $serializer
*/
public function __construct(
private readonly CacheInterface $cache,
private readonly SerializerInterface $serializer,
) {
}
/**
* Reads a cached aggregation result, if one exists.
*
* @param string $categoryId
* @param array $filters
* @return array|null
*/
public function get(string $categoryId, array $filters): ?array
{
$key = $this->buildKey($categoryId, $filters);
$cached = $this->cache->load($key);
return $cached ? $this->serializer->unserialize($cached) : null;
}
/**
* Stores an aggregation result with a limited lifetime.
*
* @param string $categoryId
* @param array $filters
* @param array $result
* @return void
*/
public function save(string $categoryId, array $filters, array $result): void
{
$key = $this->buildKey($categoryId, $filters);
$this->cache->save($this->serializer->serialize($result), $key, [self::CACHE_TAG], self::TTL_SECONDS);
}
/**
* Builds a deterministic cache key from category and filters.
*
* @param string $categoryId
* @param array $filters
* @return string
*/
private function buildKey(string $categoryId, array $filters): string
{
ksort($filters);
return self::CACHE_TAG . '_' . $categoryId . '_' . md5(json_encode($filters));
}
}
6. Hyvä implementation: Alpine loading states and debouncing filter changes
When a user clicks several checkboxes in quick succession, a naive implementation fires a separate GraphQL request with a full aggregation computation for every single click, even though only the outcome of the last click actually matters. A debounce of roughly three hundred milliseconds inside the facet component's Alpine store noticeably cuts the number of unnecessary requests without making the interaction feel sluggish to the user.
Alongside that, an AbortController that actively cancels a request still in flight as soon as a newer one starts is worth adding. Without that mechanism, the search cluster may keep computing an aggregation whose result the frontend has already discarded because the user meanwhile picked a different filter, wasting compute time that is needed elsewhere.
<div x-data="facetNavigation()" x-init="init()">
<template x-for="facet in facets" :key="facet.attribute_code">
<div class="mb-4">
<p class="font-semibold" x-text="facet.label"></p>
<template x-for="option in facet.options" :key="option.value">
<label class="flex items-center gap-2">
<input type="checkbox" @change="toggleOption(facet.attribute_code, option.value)">
<span x-text="`${option.label} (${option.count})`"></span>
</label>
</template>
</div>
</template>
<p x-show="isLoading" class="text-sm text-slate-500">Refreshing results ...</p>
</div>
<script>
function facetNavigation() {
return {
facets: [],
isLoading: false,
abortController: null,
debounceTimer: null,
toggleOption(attributeCode, value) {
clearTimeout(this.debounceTimer);
this.debounceTimer = setTimeout(() => this.fetchFacets(attributeCode, value), 300);
},
fetchFacets(attributeCode, value) {
if (this.abortController) {
this.abortController.abort();
}
this.abortController = new AbortController();
this.isLoading = true;
fetch('/graphql', { method: 'POST', signal: this.abortController.signal })
.then((response) => response.json())
.then((data) => { this.facets = data.data.products.aggregations; })
.finally(() => { this.isLoading = false; });
},
};
}
</script>
7. Monitoring: using the slow log and the profile API to find expensive aggregations
Before touching configuration or code, it pays off to check the search cluster's slow log, enabled with a threshold of, say, fifty milliseconds for the query and fetch phases. That quickly narrows down which categories and which attribute combinations actually rank among the slowest requests in live traffic, instead of optimizing on a hunch.
For a detailed analysis, the profile API in Elasticsearch and OpenSearch returns a breakdown of the time spent on each individual aggregation within a request. That makes it possible to determine precisely whether a single attribute with an unusually large number of values is responsible for most of the total time, or whether the sum of many small aggregations is the real problem, each of which calls for a very different optimization strategy.
curl -s -X POST "https://search.internal:9200/catalogsearch_product_1/_search" \
-H "Content-Type: application/json" \
-d '{
"profile": true,
"size": 0,
"aggs": {
"color_bucket": { "terms": { "field": "color", "size": 200 } }
}
}' | jq '.profile.shards[0].aggregations'
8. A practical example: measurable improvement after targeted reduction
In a typical Hyvä project with fourteen attributes marked as filterable per category, server-side aggregation time dropped noticeably once the business team helped review which attributes customers actually used for filtering. Six of them turned out to be barely or never used, and three more could be merged into a single combined filter, leaving only five active aggregations per category call in the end.
Combined with the bucket size cap and the resolver cache from the earlier sections, time to first byte on high-traffic category pages dropped significantly, which showed up directly in the Core Web Vitals and in a noticeably more responsive layered navigation. It mattered a great deal to verify every change on a staging environment with a realistically sized catalog before it went live, because small test catalogs almost always mask aggregation problems.
9. Checklist: keeping facet performance under control long term
Facet performance is not a one-time optimization project, it is an ongoing task that should be reassessed with every new attribute and every catalog expansion. A regular look at the slow log and the profile API, combined with a fixed rule for when a new attribute is even allowed to be marked filterable, keeps the problem from quietly building up again over time.
The overview below summarizes the most important levers from this article, sorted by effort and expected impact, so teams can focus first on the measures with the best ratio of effort to outcome.
| Measure | Impact on Aggregation Time | Implementation Effort | Risk of Doing Nothing |
|---|---|---|---|
| Disable unused filter attributes | Very high | Low, pure configuration | Growing aggregation load with every new attribute |
| Cap bucket size per facet | High | Medium, one plugin | Unnecessarily large responses and network overhead |
| Resolver cache for filter combinations | High | Medium to high | Repeated expensive aggregations for popular filters |
| Split the GraphQL query for filter cases | Medium | Medium, frontend change | Unnecessary overhead on every click |
| Debounce and AbortController in the Alpine store | Medium | Low | Excess parallel requests on fast clicking |
| Keep slow log and profile API on permanently | Medium, but key for early detection | Low | Problems only surface once customers complain |
Mironsoft
Hyvä theme development and Luma migration
Still running Luma, or a Hyvä theme that just doesn't feel right?
We build Hyvä themes for Magento from scratch or migrate existing Luma shops cleanly, with Tailwind CSS, Alpine.js, and none of the unnecessary JavaScript baggage.
Luma-to-Hyvä Migration
Move an existing shop to Hyvä in a structured way, without losing functionality.
Custom Theme Development
Build a custom Hyvä theme from scratch based on your design.
Performance Optimization
Improve Core Web Vitals and load times in the Hyvä frontend with purpose.
10. Summary
Facet Performance in Hyvä
Core problem
Too many and too large terms aggregations per category call delay the whole page.
Biggest lever
Disable unused filter attributes and cap bucket size server-side.
Frontend change
Keep the GraphQL query for pure filter changes lean and secure Alpine with debounce and AbortController.
Long term
Review the slow log and profile API regularly before new attributes get marked filterable.