DEV Community

Magevanta
Magevanta

Posted on • Originally published at magevanta.com

Magento 2 Layered Navigation Performance: Diagnose & Fix Slow Category Pages

Category pages are often the first thing visitors interact with — and the first thing that tanks under load. If your layered navigation takes 3+ seconds to render, you're losing conversions before a single product is clicked. This post breaks down why Magento 2 layered navigation gets slow and gives you the tools to fix it.

How Layered Navigation Works Under the Hood

Before diagnosing, you need to understand what Magento is actually doing when it renders those filter options.

When a customer visits a category page, Magento runs roughly this sequence:

  1. Load the base product collection for the category
  2. For each filterable attribute, query how many products match each option
  3. Apply any already-active filters (price, color, size, etc.)
  4. Render the filter sidebar and product grid

Step 2 is where most of the pain lives. With the default MySQL backend, Magento executes a separate query per filterable attribute to count product matches. On a store with 15 filterable attributes and 50,000 products, that's 15+ queries — each potentially scanning millions of EAV rows.

Diagnosing the Bottleneck

First, measure. Add ?XDEBUG_SESSION_START=1 or use the built-in Magento profiler to see where time is actually spent.

For a quick query-level view, enable the query log temporarily:

SET GLOBAL general_log = 'ON';
SET GLOBAL general_log_file = '/tmp/mysql_general.log';
Enter fullscreen mode Exit fullscreen mode

Then load a category page and grep the log:

grep -i "catalog_product_index_eav\|catalog_product_index_price" /tmp/mysql_general.log | wc -l
Enter fullscreen mode Exit fullscreen mode

If you see dozens of queries against catalog_product_index_eav, that's your confirmation. Disable the log when done:

SET GLOBAL general_log = 'OFF';
Enter fullscreen mode Exit fullscreen mode

The EAV Index Tables

Magento uses these index tables for layered navigation:

Table Purpose
catalog_product_index_eav Text/select attribute options
catalog_product_index_eav_decimal Decimal attributes (weight, etc.)
catalog_product_index_price Price ranges

Each of these tables has an index on (entity_id, attribute_id, store_id, value). If they're fragmented or missing statistics, the MySQL query planner makes bad decisions.

Check table health:

ANALYZE TABLE catalog_product_index_eav;
ANALYZE TABLE catalog_product_index_eav_decimal;
ANALYZE TABLE catalog_product_index_price;
OPTIMIZE TABLE catalog_product_index_eav;
Enter fullscreen mode Exit fullscreen mode

Run ANALYZE weekly via cron on large stores.

The Attribute Configuration Problem

Every attribute marked as "Use in Layered Navigation" costs query time — whether customers actually filter by it or not. This is the single most impactful configuration issue.

Go to Stores → Attributes → Product and audit every attribute:

  • Use in Layered Navigation set to Filterable (with results) or Filterable (no results) → runs a count query on every category page load
  • Used for Sorting in Product Listing → adds a sort option but also forces an extra index join

Rule of thumb: If customers don't filter by it, turn it off. Color: yes. Internal SKU suffix: no.

For attributes that must stay filterable, also check:

<!-- Attribute source model matters -->
<source_model>Magento\Eav\Model\Entity\Attribute\Source\Table</source_model>
Enter fullscreen mode Exit fullscreen mode

Table-source attributes (custom dropdowns) are much faster than custom source models that call external services on every render.

Reindex Strategy

Stale indexes are a hidden killer. Magento 2 has two indexer modes:

  • Update on Schedule (recommended) — indexes in the background via cron
  • Update on Save — blocks the save operation, dangerous at scale

Set all catalog indexes to schedule mode:

bin/magento indexer:set-mode schedule catalog_category_product
bin/magento indexer:set-mode schedule catalog_product_category
bin/magento indexer:set-mode schedule catalog_product_attribute
bin/magento indexer:set-mode schedule catalogrule_product
bin/magento indexer:set-mode schedule catalog_product_price
Enter fullscreen mode Exit fullscreen mode

Check current state:

bin/magento indexer:status
Enter fullscreen mode Exit fullscreen mode

If catalog_product_attribute shows invalid, your layered navigation is doing full-table fallback queries. Reindex it:

bin/magento indexer:reindex catalog_product_attribute
Enter fullscreen mode Exit fullscreen mode

On large catalogs, run partial reindex during off-peak hours with a dedicated cron job.

Elasticsearch / OpenSearch: The Real Fix

If you're still on MySQL-backed layered navigation with more than ~20,000 products, you're fighting the wrong battle. Elasticsearch or OpenSearch is not optional at that scale.

With ES/OS:

  • All filter counts are computed by the search engine, not MySQL
  • Aggregations run in parallel in memory
  • Category page load time drops from 3–8s to 200–500ms

Enable it in app/etc/env.php:

'system' => [
    'default' => [
        'catalog' => [
            'search' => [
                'engine' => 'elasticsearch8',
                'elasticsearch8_server_hostname' => '127.0.0.1',
                'elasticsearch8_server_port' => '9200',
                'elasticsearch8_index_prefix' => 'magento2',
            ]
        ]
    ]
]
Enter fullscreen mode Exit fullscreen mode

After switching, reindex:

bin/magento indexer:reindex catalogsearch_fulltext
Enter fullscreen mode Exit fullscreen mode

Tuning ES Aggregations

By default, Magento requests all bucket values for each attribute aggregation. On attributes with hundreds of options (e.g., manufacturer), this is expensive. Limit bucket size via the catalog_search config:

<config>
    <default>
        <catalog>
            <search>
                <max_suggestions>5</max_suggestions>
            </search>
        </catalog>
    </default>
</config>
Enter fullscreen mode Exit fullscreen mode

For custom attribute sets, consider creating a dedicated Elasticsearch index per store view and using index aliases to swap without downtime.

Caching Layered Navigation Results

Even with Elasticsearch, rendering the same filter options for every uncached page request is wasteful. Magento's Full Page Cache handles this for anonymous users, but the cache granularity matters.

Each unique combination of active filters generates a different cache entry. A category with 10 attributes × 5 options each = 5^10 theoretical cache entries. In practice you'll hit a fraction of those, but cache pollution is real.

Vary-By Tuning

Magento uses X-Magento-Vary as the cache context cookie. Verify it's being set correctly:

curl -I "https://yourstore.com/men/tops.html" | grep -i vary
Enter fullscreen mode Exit fullscreen mode

You should see Vary: X-Magento-Vary. If Varnish or nginx is stripping this header, your cache will either over-serve stale pages or never cache properly.

Custom Cache Tags for Navigation

For high-traffic stores, consider implementing a custom LayerState cache around the filter collection loading:

public function getFilters(): array
{
    $cacheKey = 'layer_filters_' . $this->layer->getCurrentCategory()->getId() 
        . '_' . $this->getActiveFiltersHash();

    $cached = $this->cache->load($cacheKey);
    if ($cached) {
        return unserialize($cached);
    }

    $filters = $this->buildFilters();
    $this->cache->save(
        serialize($filters),
        $cacheKey,
        ['CATALOG_CATEGORY', 'CATALOG_PRODUCT'],
        3600
    );

    return $filters;
}
Enter fullscreen mode Exit fullscreen mode

Tag it with CATALOG_PRODUCT so it invalidates automatically on product changes.

Price Filter Performance

The price filter is in a class of its own because prices are dynamic — they depend on customer group, catalog rules, special prices, and currency. By default, Magento recalculates price ranges on every request.

To stabilize this, set price step calculation to manual:

Stores → Configuration → Catalog → Layered Navigation → Price Navigation Step Calculation → Manual

Then set a fixed step (e.g., 50). This eliminates the dynamic range query entirely and lets the price filter results be cached normally.

For stores with customer-group pricing, you'll need to vary the cache by customer group. Add this to your custom module:

// Plugin on Magento\LayeredNavigation\Block\Navigation
public function afterGetCacheKeyInfo(Navigation $subject, array $result): array
{
    $result[] = $this->customerSession->getCustomerGroupId();
    return $result;
}
Enter fullscreen mode Exit fullscreen mode

Benchmarking Your Changes

Track the impact of each change. A minimal benchmark script:

#!/bin/bash
URL="https://yourstore.com/men/tops.html"
RUNS=5

echo "Warming cache..."
curl -s -o /dev/null "$URL"

echo "Benchmarking $RUNS runs..."
for i in $(seq 1 $RUNS); do
    time curl -s -o /dev/null -H "Cache-Control: no-cache" "$URL"
done
Enter fullscreen mode Exit fullscreen mode

Better: use k6 or wrk to simulate concurrent users and see how the database holds up under load. The real problem often only shows up with 10+ concurrent category page requests.

Quick Wins Summary

Action Impact Effort
Remove unused filterable attributes High Low
Run ANALYZE TABLE on index tables Medium Low
Switch to Update on Schedule indexing High Low
Enable Elasticsearch/OpenSearch Very High Medium
Set manual price navigation step Medium Low
Add customer-group cache variation Medium Medium
Custom filter result caching High High

Conclusion

Slow category pages are almost always a layered navigation problem. Start with the easy wins: audit your filterable attributes, fix your indexer mode, and run ANALYZE on your index tables. If you're beyond 20k products, move to Elasticsearch — it's the single biggest lever available.

The goal is consistent sub-500ms category page loads regardless of filter combinations. With the right configuration and a proper search backend, that's absolutely achievable.

Next up: we'll look at how Magento's price indexing interacts with catalog rules and why it can cause full reindexes to run during peak hours — and what to do about it.

Top comments (0)