Why Your Magento 2 Category Pages Are Slow (And How to Fix Them)
Category pages are where most Magento stores quietly lose money. They sit between search traffic and product pages, they're heavily indexed by Google, and they're structurally the most expensive template Magento renders — a single request can pull dozens of products, calculate prices for each, build a layered navigation tree with counts, and generate a grid of images.
They're also where cached-vs-uncached performance diverges most sharply. Your category page might return in 80ms from Varnish and 2.8 seconds when a filter is applied. Since filtered pages are exactly what your engaged shoppers are hitting, that gap matters more than the headline number.
This assumes your site-wide performance foundations are in place. If TTFB is bad everywhere, fix that first.
The layered navigation problem
Layered navigation is the single biggest source of category page slowness, and it gets worse in a specific pattern: fine on a 200-product category, painful on 5,000, unusable on 50,000.
Filterable attributes are not free
Every attribute set to "Use in Layered Navigation" adds work to every category page render. Magento has to compute the available options and the product count for each one, scoped to the current filter state.
Go into your attribute list and audit it honestly. Most stores have accumulated filters nobody uses — a filter with 400 options that produces one result per option isn't helping shoppers and is costing you on every page load. Ten well-chosen filters beat forty comprehensive ones on both speed and conversion.
Also check the "Use in Search Results Layered Navigation" flag separately. It's a different setting and it's easy to leave enabled on attributes you've already disabled elsewhere.
Filter combinations blow up your cache
Here's the arithmetic that catches people out. A category with 8 filters averaging 6 options each has, in principle, millions of reachable URL combinations. Varnish will happily cache every one of them — and evict everything else to make room.
Two things to do about it:
- Cap the crawlable surface. Use
robots.txtand canonical tags so search engines aren't crawling filter permutations, and configure your layered navigation tonoindexmulti-filter combinations. This is an SEO fix as much as a performance one; crawl budget wasted on filter URLs is crawl budget not spent on products. - Watch your Varnish eviction rate. If
n_lru_nukedinvarnishstatis climbing steadily, your cache is churning and your hit rate on real pages is worse than it looks.
Consider a dedicated search engine layer
Once you're past a certain catalog size, computing facets in MySQL stops being viable regardless of tuning. Magento's OpenSearch integration handles this reasonably well — but if you're on an extension that still resolves layered navigation through the catalog_product_index_eav tables, that's your bottleneck, and no amount of index tuning fixes an architectural mismatch.
Product collection bloat
Magento's product collections load more than you think. A default listing loads all attributes in the catalog_listing group, and every extension that touches the PLP tends to add its own.
The specific things to check:
Attributes marked "Used in Product Listing." Each one gets joined into the collection query. An attribute that's only used on the product detail page but flagged for listing is pure overhead multiplied by your page size.
Extensions calling addAttributeToSelect('*'). This loads every attribute on every product in the grid. It appears in a startling number of third-party modules. Grep your vendor/ and app/code/ directories for it — the fix is usually a one-line change to a specific attribute list.
Price calculation. Tier pricing, catalog price rules, and customer-group pricing all add per-product computation. If you have complex pricing rules, the price index is doing heavy lifting and needs to be healthy. Check that catalog_product_price is on Update by Schedule and actually current.
Products per page. A 60-product default grid is 60 image requests, 60 price calculations, and a much larger DOM. Test 24 or 36 against your actual conversion data — in our experience, shoppers who want more results use filters, not longer pages.
LCP on a grid of images
Product listing pages have an unusual LCP profile: the largest element is usually one of the first few product images, or a category banner. Both are fixable.
Preload the right image. Identify the LCP element in Chrome DevTools' Performance panel on a real category page — not the homepage — and preload it with fetchpriority="high".
Don't lazy-load above-the-fold products. Most themes apply loading="lazy" to every product image in the grid, including the first row. The browser then deprioritizes exactly the image that determines your LCP. Set the first 3–6 images (depending on your grid) to loading="eager" and lazy-load the rest.
Serve properly sized images. A grid thumbnail displayed at 280px should not be a 1200px source scaled down in CSS. Magento's view.xml controls image sizing per theme — make sure the category_page_grid entry matches what your CSS actually renders, and generate a responsive srcset so mobile isn't downloading desktop assets.
Reserve space. Explicit width and height on every product image, plus a fixed aspect ratio on the tile container. Grids without reserved dimensions are the most common CLS failure we see on PLPs, and it compounds — every row that shifts pushes everything below it.
Pagination, infinite scroll, and the trade-off nobody mentions
Infinite scroll converts well in some catalogs and it's a genuine SEO and performance liability in others. The honest version:
- It adds JavaScript to a page that already has plenty, and the scroll handlers hurt INP if they're not throttled properly.
- Google's crawler doesn't scroll. Products only reachable via infinite scroll may not get indexed unless you also expose paginated URLs.
- It breaks the back button unless implemented carefully, which is a real conversion cost.
If you use it, implement it as progressive enhancement over real paginated URLs with rel="next"/rel="prev" semantics, keep the initial payload small, and make sure a shopper who scrolls to product 200 and clicks through can come back to where they were.
Sorting and the index nobody checks
"Sort by Price" and "Sort by Position" perform very differently. Position sorting reads from catalog_category_product_index, which should be fast — unless that index is stale or the category has a huge product count with manual positions assigned.
Price sorting hits the price index with a customer-group scope. If you have many customer groups and websites, that index gets large fast. It's worth checking the actual row count on catalog_product_index_price; on multi-store setups with many groups it can reach a size where sorting becomes a genuine query problem.
Also: every sort option you expose multiplies your cacheable URL space. Three sort options across your filter combinations is three times the Varnish entries.
A practical audit sequence
Working through a slow PLP, this is roughly the order that finds problems fastest:
- Compare cached vs. uncached response time on a filtered URL. That gap is your real problem size.
- Enable Magento's built-in profiler or use Blackfire on an uncached filtered request and look at the query count. Anything above a few hundred queries per page render is a red flag.
- Audit filterable attributes and "Used in Product Listing" flags. This is usually the fastest meaningful win.
- Grep for
addAttributeToSelect('*'). - Fix the image layer: eager-load the first row, correct
view.xmlsizes, add dimensions. - Re-baseline in field data after two weeks.
Category pages reward this work more than most templates, because the fixes compound — a leaner collection query makes the uncached path faster, which makes cache misses less costly, which makes your filter strategy less risky.
Emyrix does Magento performance profiling and Core Web Vitals work for merchants and agencies — including the catalog architecture decisions behind slow listing pages. If your category pages are the weak point, let's talk.