← Back to blog

Magento 2 GraphQL Caching and Performance: Practical Guide

Rodrigo Biazzi

A headless or hybrid Magento storefront lives or dies on GraphQL response times. A category page that fires five queries, each taking 800 ms on a cache miss, is not going to feel fast no matter how good the frontend is. The good news is that Magento GraphQL can be cached by Varnish or Fastly much like regular pages, and most slow GraphQL setups are slow because that caching is not working, not because GraphQL is slow by nature. This article covers how GraphQL caching works in Magento 2.4.8, how to make your own resolvers cacheable, and the server side work that matters on cache misses.

How Magento caches GraphQL

The Magento_GraphQlCache module adds full page cache support to GraphQL. The rules are simple:

  • Only queries sent with HTTP GET can be cached. POST requests always go to PHP.
  • Mutations are never cached and are sent with POST.
  • A query is cacheable only if every resolver it touches is cacheable. One non cacheable field makes the whole response uncacheable.
  • Responses carry cache tags (in the X-Magento-Tags header, which Varnish strips before sending to the client) so that saving a product or category purges the right entries.

The cache works with the built in full page cache, with Varnish, and with Fastly on Adobe Commerce Cloud. For production use Varnish or Fastly. The VCL that Magento generates already contains the GraphQL rules. If you run Varnish, regenerate the VCL after upgrades, as explained in Varnish full page cache for Magento 2.

Send queries as GET

Many GraphQL clients default to POST for everything. With Apollo Client or urql you need to turn on GET for queries explicitly. If you check your storefront's network tab and every query is a POST, nothing is being cached and every visitor hits PHP.

curl -G 'https://shop.example.com/graphql' \
  -H 'Store: default' \
  -H 'Content-Currency: EUR' \
  --data-urlencode 'query={products(filter:{sku:{eq:"24-MB01"}}){items{name sku}}}'

Keep queries compact. A very long query string in the URL can hit URL length limits in the web server or a proxy. Strip whitespace and avoid requesting fields the page does not use.

Cache variation: store, currency and customer context

The same query can return different data depending on context. Magento varies the GraphQL cache by headers that describe that context:

  • Store selects the store view. Always send it explicitly.
  • Content-Currency selects the display currency when the store has more than one.
  • Authorization identifies a logged in customer, whose prices and catalog rules may differ by customer group.

Recent Magento versions return an X-Magento-Cache-Id header in GraphQL responses. It is a hash of the context that affects caching (store, currency, customer group and similar). The client should store the latest value and send it back as a request header on following queries. Varnish uses it to select the right cached variant. Requests that carry a customer token but no cache id are passed to the backend, so a client that ignores this header gets no caching for logged in customers.

let cacheId = null;

async function gql(query, variables, token) {
  const url = new URL('https://shop.example.com/graphql');
  url.searchParams.set('query', query);
  if (variables) {
    url.searchParams.set('variables', JSON.stringify(variables));
  }

  const headers = { Store: 'default' };
  if (token) {
    headers.Authorization = 'Bearer ' + token;
  }
  if (cacheId) {
    headers['X-Magento-Cache-Id'] = cacheId;
  }

  const response = await fetch(url, { headers });
  const newId = response.headers.get('X-Magento-Cache-Id');
  if (newId) {
    cacheId = newId;
  }
  return response.json();
}

If the frontend runs on a different origin, make sure CORS allows and exposes the header, otherwise the browser will hide it from your code.

Making custom resolvers cacheable

Custom queries are not cacheable by default in a useful way unless you tell Magento how to tag them. You do that in schema.graphqls with the @cache directive, pointing to an identity class that returns the cache tags for a result.

type Query {
    storeLocations(country: String!): [StoreLocation]
        @resolver(class: "Acme\\Locator\\Model\\Resolver\\StoreLocations")
        @cache(cacheIdentity: "Acme\\Locator\\Model\\Resolver\\StoreLocations\\Identity")
}
<?php
declare(strict_types=1);

namespace Acme\Locator\Model\Resolver\StoreLocations;

use Magento\Framework\GraphQl\Query\Resolver\IdentityInterface;

class Identity implements IdentityInterface
{
    private const CACHE_TAG = 'acme_location';

    public function getIdentities(array $resolvedData): array
    {
        $ids = [];
        foreach ($resolvedData as $location) {
            if (isset($location['entity_id'])) {
                $ids[] = self::CACHE_TAG . '_' . $location['entity_id'];
            }
        }
        if ($ids) {
            $ids[] = self::CACHE_TAG;
        }

        return $ids;
    }
}

For purging to work, the model that saves a location must clean the same tags. If the model implements Magento\Framework\DataObject\IdentityInterface and returns acme_location_{id} from getIdentities(), Magento's cache cleaning on save handles both the regular page cache and the GraphQL entries. If you skip this step, cached responses will keep showing old data until the cache is flushed.

Fields that must never be cached, such as anything specific to the current customer or cart, are marked with @cache(cacheable: false). Use it on the field that really varies, not on a whole query, so the rest stays cacheable. Writing the resolver itself is covered in custom GraphQL query resolvers.

Checking whether caching works

In developer mode, responses include an X-Magento-Cache-Debug header with HIT or MISS. On production, check Varnish directly:

varnishlog -g request -q 'ReqURL ~ "^/graphql"' -i ReqURL -i VCL_call

Run the same GET query twice. The second one should be a hit. If it is always a miss, look for these causes:

  • The query is a POST.
  • One of the fields is not cacheable. Remove fields one by one until it caches, and you find the culprit.
  • The request carries a Authorization header without X-Magento-Cache-Id.
  • A cookie or header sent by the frontend makes Varnish pass the request. Compare with the rules in your VCL.

Performance on cache misses

Caching hides problems until a deploy or a catalog import purges everything. On cache misses the work happens in PHP and MySQL, and GraphQL has some traps of its own.

The N+1 problem

A resolver for a field on a list item runs once per item. If a products query returns 48 items and your custom field resolver loads a model for each one, that is 48 extra queries. Magento provides batch resolver interfaces in Magento\Framework\GraphQl\Query\Resolver, such as BatchResolverInterface, which receive all requests for a field at once so you can load the data in one query. Use them for any custom field added to products or categories.

Ask for less

Frontends often request the same big fragment for every product list, including fields only the product page uses. Each field has a cost. Requesting media gallery, all custom attributes and full price ranges for 48 products on a category page adds up. Keep list fragments small and request details on the product page.

Limit query depth and complexity

Magento limits query depth and complexity to protect the server from huge queries. The limits are constructor arguments of Magento\Framework\GraphQl\Query\QueryComplexityLimiter and can be changed in di.xml. Lower is safer. Only raise them if a real frontend query needs it.

<type name="Magento\Framework\GraphQl\Query\QueryComplexityLimiter">
    <arguments>
        <argument name="queryDepth" xsi:type="number">15</argument>
        <argument name="queryComplexity" xsi:type="number">250</argument>
    </arguments>
</type>

The usual backend basics

GraphQL uses the same models, indexes and caches as the rest of Magento. Indexers in schedule mode, Redis for cache and sessions, OpenSearch tuned for the catalog, and PHP-FPM with enough workers all matter just as much here. A slow products query with a search term is often an OpenSearch problem, not a GraphQL one.

Frontend side caching

A headless frontend usually adds its own cache layer: framework data caches, CDN caching of rendered pages, or a client cache like Apollo's. That is useful, but it needs an invalidation strategy. If Magento purges Varnish when a price changes and the frontend keeps a rendered page for an hour, customers still see the old price. Decide which layer owns freshness for each type of data. Prices and stock usually need short lifetimes or event based revalidation. CMS content and category trees can live longer. The headless Next.js storefront article shows one way to handle this with revalidation tags.

Finally, measure. Log GraphQL operation names and timings at the frontend, watch the Varnish hit rate for /graphql, and profile slow resolvers with a tool like New Relic or Blackfire. Without numbers, GraphQL optimization turns into guessing.

FAQ

Why are my Magento GraphQL queries never cached?

Usually because they are sent as POST, because one field in the query is not cacheable, or because a logged in request carries a token without the X-Magento-Cache-Id header.

Can GraphQL mutations be cached?

No. Mutations change data and always go to PHP. Only queries sent with GET are cacheable.

How do I make a custom GraphQL query cacheable?

Add @cache(cacheIdentity: ...) to the query in schema.graphqls and implement an identity class that returns cache tags. Make sure the model cleans the same tags on save.

What does X-Magento-Cache-Id do?

It identifies the caching context such as store, currency and customer group. The client sends back the last value it received so Varnish can serve the right cached variant.

I am available for freelance Magento and Adobe Commerce performance work. If your GraphQL API is slow, get in touch.

Related articles