Elasticsearch and OpenSearch Facets in Hyvä: Keeping Aggregations Fast
AI generated
Hyvä
phtml
Hyvä Theme
Elasticsearch and OpenSearch Facets
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.

13 min read Elasticsearch · OpenSearch Layered Navigation

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.

11. FAQ: Facet Performance in Hyvä

1Why do facets in Elasticsearch and OpenSearch get slower the more values an attribute has?
Every terms aggregation has to count hits for every single value of the attribute within the current product set. The more distinct values an attribute has, the more buckets the cluster has to compute and return, which directly increases response time.
2Is it enough to just reduce the number of facets shown on the page?
No, because Magento's default behavior often keeps computing all configured aggregations even if only part of them are displayed in the frontend. The limit has to be applied server-side or through attribute configuration, not just visually in the template.
3Does behavior differ noticeably between Elasticsearch and OpenSearch?
The core aggregation logic is very similar, but default values for circuit breakers and maximum result sizes differ, so identically configured categories can behave differently depending on the cluster type. Testing against the cluster actually in use is essential.
4Does the Full Page Cache help with filtered category pages?
Only to a limited extent, because every filter combination produces its own GraphQL request that is usually not covered by the Full Page Cache at all. An additional Redis-based resolver cache is significantly more effective here.
5How long should the TTL be for a facet result cache?
Five to ten minutes is a good starting point for most catalogs, since stock levels rarely need to be accurate to the second. For catalogs with heavily fluctuating stock, a shorter TTL combined with event-based invalidation is advisable.
6What does splitting the GraphQL query into product list and facets actually achieve?
On a pure filter change, only the facet area needs to reload while product images and descriptions can stay unchanged in the frontend. A dedicated query reduces both the resolver work and the amount of data transferred.
7Why does a debounce in the Alpine store make sense for filters?
Without a debounce, every single checkbox click immediately triggers a new, aggregation-heavy request, even though only the result of the last click matters to the user. A short debounce bundles fast click sequences into a single request.
8What does the profile API in Elasticsearch and OpenSearch actually provide?
It returns a detailed time breakdown per aggregation within a request, showing which attribute is actually responsible for most of the compute time. Without that tool, optimization is often pure guesswork.
9Should price range facets always be loaded?
Not necessarily, since histogram computations for price ranges are among the more expensive aggregations. The GraphQL @include directive lets you request them only when the price filter widget is actually visible in the current viewport.
10How do you reliably test facet performance changes?
Best done on a staging environment with a catalog that realistically reflects the size and attribute diversity of the live shop. Small test catalogs almost always mask aggregation problems because the bucket count per attribute stays artificially small.