with Magento GraphQL
The category page is the most important performance hotspot in almost every Magento shop. Too many fields, resolver chains that run too deep, and missing caching strategies make it slower than it needs to be. The lever is in the query, not the server.
Table of Contents
- 1. Why category pages are the most important GraphQL hotspot
- 2. Anatomy of a category page query
- 3. Reducing fields: only request what gets rendered
- 4. Modeling aggregations and filters efficiently
- 5. Detecting and fixing N+1 problems in resolver chains
- 6. Pagination: cursor vs. offset in Magento
- 7. Caching strategy for category page queries
- 8. Common mistakes in category page queries
- 9. Wrong vs. right: query comparison
- 10. Summary
- 11. FAQ
1. Why category pages are the most important GraphQL hotspot
The category page is the most visited page type in almost every Magento shop, and at the same time the most expensive GraphQL request. A typical category page loads a product listing with images, prices, and variants, plus filter aggregations, a total count for pagination, and category metadata. All of that arrives in a single GraphQL query, and if that query is not carefully designed, it drives up resolver costs that neither hardware nor caching can fully compensate for.
The decisive difference from REST: with REST, the response structure is defined on the server side. With GraphQL, the client decides which fields it requests, and therefore also how expensive the request becomes. Frontends that simply request "everything available" during development create resolver chains in Magento that query EAV attributes, images, prices, and stock status individually for every single product. The result is hundreds of database accesses for a single page.
2. Anatomy of a category page query
A complete category page query in Magento typically has three main sections: the product listing with the fields that get rendered for the tiles on the page, the aggregations for the filter sidebar, and page_info for pagination. On top of that there is often category metadata needed for SEO, breadcrumbs, and the page header.
Each of these sections has a different caching profile and a different resolver depth. The product listing is the most expensive because every product has individual EAV attributes, price rules, and stock information. Aggregations are cheaper because they are aggregated at the database level. Pagination information is cheap. Category metadata is usually cacheable. Anyone who knows these four sections and understands their relative cost contribution can optimize the query in a targeted way.
# Optimized category page query: only request fields that are actually rendered
# Split into logical sections with documented cost profile
query CategoryPage(
$categoryId: String!
$pageSize: Int!
$currentPage: Int!
$sort: ProductAttributeSortInput
$filter: ProductAttributeFilterInput
) {
# Section 1: category metadata (cheapest, cacheable)
categoryList(filters: { ids: { eq: $categoryId } }) {
uid
name
description
meta_title
meta_description
image
breadcrumbs {
category_id
category_name
category_url_key
}
}
# Section 2: product listing (most expensive, EAV, prices, images)
products(
filter: $filter
pageSize: $pageSize
currentPage: $currentPage
sort: $sort
) {
# Section 3: pagination info (cheap)
total_count
page_info {
current_page
page_size
total_pages
}
# Section 4: product tiles, minimal fields only
items {
uid
sku
name
url_key
url_suffix
# Image: request only the required size
small_image {
url
label
}
# Price: minimum_price only, avoid full price_range for listings
price_range {
minimum_price {
regular_price { value currency }
final_price { value currency }
discount { amount_off percent_off }
}
}
# Rating for listing display
rating_summary
review_count
}
}
}
3. Reducing fields: only request what gets rendered
The most effective optimization tool for category page queries is consistently trimming down to the fields that are actually rendered on the page. Every additional field in a product listing query can trigger one or more database accesses per product. With 24 products per page, every unnecessary field means up to 24 unnecessary queries.
The most common candidates for fields that get requested but not rendered: description (a long HTML text shown on the product detail page, not on the listing page), short_description (often not shown in tiles), media_gallery (all images instead of just small_image), related_products (a resolver explosion: related products get loaded for every single product), and price_range with maximum_price (more expensive than minimum_price alone). A systematic comparison between the requested fields and the actual rendering template is the most efficient way to uncover hidden performance losses.
4. Modeling aggregations and filters efficiently
Aggregations are the filter options in the sidebar: color, size, price, brand, rating. In Magento they arrive via the aggregations field of the product query and are computed by OpenSearch. That means aggregations represent a separate request to the search backend, expensive but cacheable. The important optimization point is the selection of aggregated attributes: if all 50 EAV attributes are marked as aggregatable, the aggregation computation becomes correspondingly more costly.
Practical recommendation: only mark attributes as filterable that are actually shown in the sidebar. That reduces aggregation costs and shrinks the response payload. In the GraphQL query you can additionally limit the number of aggregation options via aggregations { options(limit: 10) }: for attributes with many possible values (for example manufacturers with hundreds of options), a sensible limit is both better UX and faster to render.
# Aggregations query: efficient filter sidebar loading
# Request only needed aggregation attributes, limit options per filter
query CategoryFilters(
$filter: ProductAttributeFilterInput!
$pageSize: Int!
) {
products(filter: $filter, pageSize: $pageSize) {
total_count
# Aggregations for filter sidebar: only requested attributes returned
aggregations(filter: { position: { from: "0" to: "100" } }) {
attribute_code
label
count
# Limit options to prevent huge payloads for attributes with many values
options {
label
value
count
}
}
items {
uid
sku
name
url_key
small_image { url label }
price_range {
minimum_price {
final_price { value currency }
}
}
}
page_info {
current_page
page_size
total_pages
}
}
}
5. Detecting and fixing N+1 problems in resolver chains
The N+1 problem is the most common performance anti-pattern in GraphQL APIs, and it shows up particularly clearly on Magento category pages. The basic pattern: a resolver for a list loads N products, then for each product a further resolver fetches an associated object, N additional queries. With 24 products, a single query turns into 25 database accesses. With deeper nesting (products, then categories, then parent categories), the number of accesses grows exponentially.
In Magento, the typical N+1 scenario is the combination of a product listing with individual attributes or category assignments. Magento itself uses internal batching mechanisms for many fields, but custom extensions implementing their own resolvers are frequently vulnerable. The fix is either DataLoader-style batching (collect all IDs, load them in one query) or moving the data access one level up, so that all the data that is needed gets preloaded in the parent resolver method. The tool for diagnosis: bin/log graphql.log and query profiling with debugging enabled.
6. Pagination: cursor vs. offset in Magento
Magento GraphQL uses offset-based pagination via currentPage and pageSize. That is the simpler of the two pagination styles and works well enough for e-commerce category pages with typical page sizes of 12 to 48 products. Cursor-based pagination would be more performant for very large datasets (because there is no OFFSET in SQL), but it is not natively available in Magento and has to be built explicitly for custom implementations.
An important performance aspect with offset pagination: the deeper the page, the more expensive the database query. Page 100 with a page size of 24 means that MySQL or OpenSearch has to discard the first 2,376 results before page 100 can be delivered. For category pages, which normally are not paginated very deep, that is not a practical problem. For importers or crawlers that systematically work through every page, it can become a bottleneck. In those cases, a separate API endpoint with cursor pagination or an ID-based iteration approach is recommended.
7. Caching strategy for category page queries
Category pages are among the most cacheable content in a shop: product listings do not change on every request, prices stay the same for standard customers, and aggregations remain stable until the next catalog update. The caching strategy for category page queries has three levels: HTTP caching for anonymous requests, server-side query result caching for complex resolver results, and edge caching via a CDN for geographically distributed traffic spikes.
In Magento, the built-in Varnish caching for GraphQL endpoints is active by default. The problem: Magento invalidates the cache aggressively on catalog changes, a single price change on one product can clear the cache for hundreds of category pages. For headless setups with Hyvä or a separate frontend server, a more granular invalidation approach via cache tags is recommended: every query response is tagged with the relevant product and category IDs, and invalidation happens selectively only for the affected pages.
8. Common mistakes in category page queries
The most common mistake in category page queries is requesting related_products or upsell_products in the context of the product listing. These fields trigger an additional resolver call for every product in the list, which in turn loads a full product listing of its own. That creates an exponentially growing number of database accesses that can lead to timeouts even with small product listings. Related products belong on the detail page, not in the category listing query.
A second typical mistake: query variables are not passed via GraphQL variables but embedded directly into the query. That prevents persisted queries and makes HTTP caching significantly less efficient, because every query variation gets treated as a separate cache key. All the variable parts of a category page query, filter, page number, page size, sorting, should be passed as variable arguments so that tooling and caching work optimally.
| Problem | Symptom | Solution | Effort |
|---|---|---|---|
| Too many fields | High query time, large payload | Limit fields to what is actually rendered | Low |
| N+1 in resolvers | Many SQL queries, slow pages | Introduce batching in the resolver | Medium |
| related_products in listing | Exponential query increase | Remove field from category listing | Low |
| No query caching | Every page loaded fresh from the DB | Configure Varnish / CDN caching | Medium |
| Hardcoded values in query | Cache fragmentation, no persisted query | Pass all variable values as variables | Low |
9. Wrong vs. right: query comparison
The most direct way to demonstrate the benefit of category page optimizations is to compare two queries for the same use case: an unoptimized query that requests everything available, and an optimized query that only renders what the page needs. The difference in resolver depth and the number of database accesses is often substantial, double-digit factors in query time are not unusual in practice.
The optimized query is not less "correct" than the unoptimized one, it is deliberately constrained. That deliberateness is the difference between a GraphQL endpoint that happens to be performant and one that was structurally designed for performance. The deliberate constraint deserves to be documented: why is description not requested? Because it is not rendered on the category page and can be loaded separately on the detail page. That documentation belongs as a comment in the query fixture file, not in a wiki document nobody reads.
# WRONG: Over-fetching on category listing page
# Causes: deep resolver chains, N+1 for related products, large payload
query CategoryPageBad($categoryId: String!) {
products(filter: { category_id: { eq: $categoryId } }, pageSize: 24) {
items {
sku
name
url_key
description { html } # never shown in listing, expensive
short_description { html } # usually not shown in listing either
media_gallery { # all images, only thumbnail needed
url
label
position
}
price_range {
minimum_price { regular_price { value } final_price { value } discount { amount_off percent_off } }
maximum_price { regular_price { value } final_price { value } } # not needed
}
related_products { # N+1 explosion: 24 * N extra queries
sku
name
price_range { minimum_price { final_price { value } } }
}
upsell_products { sku name } # same issue as related_products
crosssell_products { sku }
}
}
}
# RIGHT: Lean category listing, only fields actually rendered
# Each field justified by a component that renders it
query CategoryPageGood(
$filter: ProductAttributeFilterInput!
$pageSize: Int!
$currentPage: Int!
$sort: ProductAttributeSortInput
) {
products(
filter: $filter
pageSize: $pageSize
currentPage: $currentPage
sort: $sort
) {
total_count
page_info { current_page page_size total_pages }
items {
uid
sku # needed for cart operations
name # rendered in product tile
url_key # needed for product link
small_image { url label } # single thumbnail image
price_range {
minimum_price {
regular_price { value currency }
final_price { value currency }
discount { percent_off } # for sale badge
}
}
rating_summary # star rating in tile
review_count # review count badge
}
}
}
Optimizing Category Pages with Magento GraphQL: the key takeaways at a glance
Reduce fields
Only request fields that are actually rendered on the category page. description, media_gallery, and related_products do not belong in the listing page query.
Fix N+1
Secure resolver chains with batching. related_products in the product listing is the most common N+1 pattern, always remove it from category page queries.
Use variables
Filter, sorting, and pagination always as GraphQL variables, not hardcoded. Enables persisted queries and efficient HTTP caching.
Enable caching
Varnish caching for anonymous requests, granular cache tag invalidation for headless setups. Category pages are highly cacheable.
10. Summary
Optimizing category pages with Magento GraphQL means, above all, making deliberate decisions about which fields are genuinely needed and what resolver depth is acceptable. The biggest lever is in the query itself, not the infrastructure. A lean, well-built category page query with correct variables, no N+1 fields, and appropriate caching headers delivers better results than an oversized server behind a poorly optimized query.
The practical steps in the right order: first measure, which fields are actually rendered, how long does the query take, how many SQL queries does it generate? Then reduce, remove every field that rendering does not need. Then identify N+1 patterns and introduce batching. Finally configure caching and validate that invalidation works correctly. This order ensures that optimization effort gets invested where the actual bottleneck lies.