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:
- Load the base product collection for the category
- For each filterable attribute, query how many products match each option
- Apply any already-active filters (price, color, size, etc.)
- 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';
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
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';
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;
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)orFilterable (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>
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
Check current state:
bin/magento indexer:status
If catalog_product_attribute shows invalid, your layered navigation is doing full-table fallback queries. Reindex it:
bin/magento indexer:reindex catalog_product_attribute
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',
]
]
]
]
After switching, reindex:
bin/magento indexer:reindex catalogsearch_fulltext
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>
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
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;
}
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;
}
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
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)