Headers, tags and invalidation for high-performance storefronts
GraphQL caching in Varnish is not an automatic side effect, it is a deliberate architectural decision: persisted queries turn variable POST bodies into cacheable GET requests, X-Magento-Tags enable precise invalidation, and IdentityInterface decides which resolvers are even allowed to contribute to the cache. This article walks through the full path from request to invalidation.
Table of Contents
- 1. Why GraphQL POST requests are not cacheable by default
- 2. Persisted queries and GET requests as a prerequisite for full-page caching
- 3. Understanding cache headers: Cache-Control, X-Magento-Cache-Id, X-Magento-Tags
- 4. Making custom resolvers cache-aware: IdentityInterface
- 5. Adjusting Varnish VCL for /graphql
- 6. Cache invalidation on data changes
- 7. Query complexity and rate limiting as a precursor to caching
- 8. Pitfalls with personalized fields
- 9. Monitoring cache effectiveness
- 10. Summary
- 11. FAQ
1. Why GraphQL POST requests are not cacheable by default
HTTP caching in Varnish is built on a simple model: the combination of HTTP method and URL, extended by selected headers via Vary, forms the cache key. A GraphQL endpoint fundamentally breaks this model because it is served on a single URL, /graphql, using the HTTP method POST, regardless of whether a product detail page, a faceted category, or a CMS landing page is being requested. The actual request, meaning which fields, which arguments and which nested objects are wanted, lives entirely in the request body. To Varnish, two completely different GraphQL caching requests look identical: same method, same URL, same Content-Type.
Varnish does not read the body of POST requests into the hash key by default, and for good reason: GraphQL query bodies can be arbitrarily large, arbitrarily deeply nested, and combined with arbitrary variables. Hashing the entire body would produce a cache key per exact string, so even minor formatting differences (whitespace, field order, variable names) would cause an explosion of cache entries with almost no hit rate. The consequence for high-traffic storefronts: exactly the requests that repeat most often (category pages, product search, header and footer data) pass through Varnish unthrottled and land directly on PHP-FPM and the database.
Working GraphQL caching has to attack the transport layer, not the data model. The fix is not teaching Varnish to hash POST bodies, it is reshaping GraphQL requests so they fit the GET-plus-URL model that HTTP caches were built for. That is exactly what persisted queries accomplish, combined with the cache-relevant headers that Magento's GraphQL stack emits per response.
2. Persisted queries and GET requests as a prerequisite for full-page caching
A persisted query flips the relationship between client and server: instead of sending the full query structure in the body on every request, the client registers the query once against a stable hash, typically a SHA-256 hash of the query text. From then on, sending that hash as a GET parameter is enough. The Automatic Persisted Queries (APQ) protocol, as implemented by Apollo Client, works in two steps: the client first sends only the hash via GET, the server checks whether it already knows the query, and responds with PersistedQueryNotFound if it does not. Only then does the client send the full query once via POST; every subsequent call only needs the hash. For Varnish this means an arbitrarily complex POST request turns into a GET request with a fixed, short query string structure that can be hashed and used as a cache key without trouble.
Magento's /graphql endpoint already supports GET requests in core and accepts query, variables and operationName as query string parameters. That alone does not solve GraphQL caching, because the full query still sits in the URL and can vary depending on client formatting, but it is the technical prerequisite that persisted queries build on. Combined with the extensions.persistedQuery field from the APQ protocol, the query string becomes a stable, short hash instead of an arbitrarily long string.
In practice, persisted-query caching pays off most for recurring, site-wide queries: header navigation, category trees, CMS blocks and product listing queries with stable arguments. Individual, ad-hoc composed queries from a GraphQL playground or from experimental frontend features barely benefit, because the hash changes with every structural change and the GraphQL cache would need to be rebuilt constantly for them.
# Automatic Persisted Query (APQ) request as GET - stable, cacheable cache key
# The client only sends the sha256 hash of the previously registered query,
# not the query text itself. Varnish can hash this URL like any static GET request.
curl -G 'https://mironsoft.de/graphql' \
--data-urlencode 'extensions={"persistedQuery":{"version":1,"sha256Hash":"c00590fae0a1a4396a5f5f4cf35a68b8"}}' \
--data-urlencode 'variables={"sku":"MS-2026"}' \
-H 'Store: default' \
-H 'Content-Currency: EUR'
# First-time registration flow (server does not know the hash yet):
# 1. GET with hash only -> server responds: {"errors":[{"message":"PersistedQueryNotFound", ...}]}
# 2. Client resends once via POST with both query text and matching sha256Hash
# 3. All subsequent requests use step 1 (GET + hash) and are fully cacheable
3. Understanding cache headers: Cache-Control, X-Magento-Cache-Id, X-Magento-Tags
Once a GraphQL request reaches Magento as a GET, the Magento_GraphQlCache module decides per response whether and how it may be cached. Central to this is the class Magento\GraphQlCache\Model\CacheableQuery, available as an extension attribute on the context during resolver execution, which collects all cache tags contributed by the involved resolvers. If the query ends up marked as cacheable, Magento emits a Cache-Control header with max-age and public, along with X-Magento-Cache-Id as a unique identifier for that specific response, and X-Magento-Tags with the comma-separated list of all cache tags relevant to that response.
X-Magento-Cache-Id is the actual cache key component: it is built from store ID, query signature, and relevant context values such as customer group or currency, so that two responses that differ in content (for example different prices per customer group) never hit the same cache entry. X-Magento-Tags, on the other hand, has nothing to do with the actual caching decision, it is purely relevant for later invalidation: Varnish reads this header in vcl_backend_response and stores the tags alongside the object, so a later PURGE request can remove exactly the affected entries instead of flushing the entire GraphQL cache.
If either header is missing, or if Cache-Control is set to no-store, Varnish will not cache the response at all, even if the VCL configuration would technically be capable of it. That decision is not made by Varnish, but by Magento's GraphQL caching layer on the PHP side, based on whether every involved resolver was declared cacheable.
# Inspect the cache-relevant response headers for a persisted GraphQL query
curl -I -G 'https://mironsoft.de/graphql' \
--data-urlencode 'extensions={"persistedQuery":{"version":1,"sha256Hash":"c00590fae0a1a4396a5f5f4cf35a68b8"}}' \
-H 'Store: default'
# Example response headers on a cacheable GraphQL cache hit:
# HTTP/2 200
# content-type: application/json
# cache-control: max-age=86400, public, s-maxage=86400
# x-magento-cache-id: 3f9a2e1c8b6d4a7f0e1c2b3a4d5e6f70
# x-magento-tags: cat_p_1024,cat_c_45,catalog_product,MAGE
# x-magento-cache-debug: HIT
# age: 412
4. Making custom resolvers cache-aware: IdentityInterface
A custom GraphQL resolver only becomes part of GraphQL caching once it tells the aggregator which cache tags its data produces. To do that, the resolver implements Magento\Framework\GraphQl\Query\Resolver\IdentityInterface in addition to ResolverInterface, with the method getIdentities(array $resolvedData): array. This method receives the data the resolver has already resolved and returns an array of tags, usually following the pattern of existing Magento entity tags such as cat_p_<id> for products, or custom, module-specific tags.
If a resolver does not implement IdentityInterface, that does not automatically make the entire query uncacheable, but it does mean that part of the response contributes no tags for invalidation. The more critical case is the reverse: a resolver that returns personalized or session-dependent data and actively calls CacheableQuery::setCacheValidity(false) marks the entire response as non-cacheable, no matter how many other resolvers in the same query return cacheable data. A single misconfigured resolver can thereby defeat GraphQL caching for an otherwise fully cacheable page query.
The cache tags that getIdentities() returns deliberately follow the same conventions as the classic Magento page cache. That has a practical benefit: existing invalidation logic, for example observers that pass tags to the cache flush on a product save, does not need to be rewritten for GraphQL caching, it benefits from the same tags automatically.
declare(strict_types=1);
namespace Mironsoft\CatalogGraphQl\Model\Resolver;
use Magento\Framework\GraphQl\Config\Element\Field;
use Magento\Framework\GraphQl\Exception\GraphQlInputException;
use Magento\Framework\GraphQl\Query\Resolver\ContextInterface;
use Magento\Framework\GraphQl\Query\Resolver\IdentityInterface;
use Magento\Framework\GraphQl\Query\ResolverInterface;
use Magento\Framework\GraphQl\Schema\Type\ResolveInfo;
use Mironsoft\Badges\Api\BadgeRepositoryInterface;
/**
* Resolver for the "product_badges" GraphQL field, declared cacheable via IdentityInterface.
*/
final class ProductBadges implements ResolverInterface, IdentityInterface
{
/**
* @param BadgeRepositoryInterface $badgeRepository Read-only repository for product badge data.
*/
public function __construct(
private readonly BadgeRepositoryInterface $badgeRepository,
) {
}
/**
* @param Field $field GraphQL field configuration.
* @param ContextInterface $context Resolver execution context.
* @param ResolveInfo $info Resolve tree metadata.
* @param array|null $value Parent resolver value, contains the product SKU.
* @param array|null $args GraphQL field arguments.
* @return array Resolved data made available to getIdentities().
* @throws GraphQlInputException When the parent SKU is missing.
*/
public function resolve(Field $field, $context, ResolveInfo $info, array $value = null, array $args = null): array
{
$sku = $value['sku'] ?? null;
if ($sku === null) {
throw new GraphQlInputException(__('SKU is missing from the parent resolver.'));
}
return [
'sku' => $sku,
'badges' => $this->badgeRepository->getBySku($sku),
];
}
/**
* @param array $resolvedData Data returned from resolve(), used to derive cache tags.
* @return string[] Cache tags that invalidate this field when the underlying product changes.
*/
public function getIdentities(array $resolvedData): array
{
if (!isset($resolvedData['sku'])) {
return [];
}
return ['cat_p_' . $resolvedData['sku']];
}
}
5. Adjusting Varnish VCL for /graphql
Magento's default VCL (vcl_recv) treats /graphql no differently from any other endpoint, which without adjustment means that different store views, currencies or customer groups could hit the same cache entry even though the responses contain different prices. For correct GraphQL caching, vcl_recv must explicitly ensure that GET requests to /graphql are hashed using the query string (which carries the persisted-query hash) as well as relevant headers such as Store, Content-Currency and the customer group. Without this extension, Varnish could, for example, serve B2B special pricing for one customer group to anonymous visitors.
In vcl_backend_response, the X-Magento-Tags set by Magento must additionally be preserved, usually via a module like vmod_header or directly through beresp.http.X-Magento-Tags, so they are available for the later PURGE-based invalidation. The standard Magento VCL ban mechanism that already exists (ban based on obj.http.X-Magento-Tags) works identically for GraphQL responses as it does for classic HTML pages, as long as the tags are correctly passed through.
sub vcl_recv {
# Only apply GraphQL-specific caching logic to GET requests on /graphql
if (req.url ~ "^/graphql" && req.method == "GET") {
# Persisted query hash and variables live in the query string -
# this becomes part of Varnish's default hash key automatically.
# Explicitly fold relevant context headers into the request
# so price/currency/customer-group variants never collide.
set req.http.X-Gql-Store = req.http.Store;
set req.http.X-Gql-Currency = req.http.Content-Currency;
set req.http.X-Gql-Customer-Group = req.http.X-Magento-Customer-Group;
} else if (req.url ~ "^/graphql" && req.method == "POST") {
# Non-persisted POST queries bypass the cache entirely - pass through.
return (pass);
}
}
sub vcl_hash {
if (req.url ~ "^/graphql") {
hash_data(req.http.X-Gql-Store);
hash_data(req.http.X-Gql-Currency);
hash_data(req.http.X-Gql-Customer-Group);
}
}
sub vcl_backend_response {
if (bereq.url ~ "^/graphql" && beresp.http.X-Magento-Tags) {
# Preserve tags on the cached object for later ban-based invalidation
set beresp.http.X-Magento-Tags = beresp.http.X-Magento-Tags;
set beresp.ttl = 24h;
set beresp.grace = 6h;
}
}
sub vcl_deliver {
if (req.url ~ "^/graphql") {
set resp.http.X-Magento-Cache-Debug = obj.hits > 0 ? "HIT" : "MISS";
}
}
6. Cache invalidation on data changes
The invalidation mechanism for GraphQL caching is not fundamentally different from the classic Magento page cache: it is tag-based, not time-based. When an editor saves a product, Magento collects all affected cache tags during the save, typically cat_p_<id> for the product itself as well as the tags of every category it is placed in. This tag list is passed to the configured cache flush mechanism, which, with an enabled Varnish backend, sends a PURGE or BAN request to all configured Varnish instances.
Because GraphQL resolvers use the same tag conventions as the HTML page cache via IdentityInterface, exactly the same PURGE flow applies: Varnish receives a ban request with an expression such as obj.http.X-Magento-Tags ~ cat_p_1024 and removes all cached objects, HTML pages as well as GraphQL responses, that carry that tag. A single product save thereby invalidates both the classic product detail page and every GraphQL cache entry that contained that product's data, in one step.
The practical benefit of this model: no separate invalidation system is needed for GraphQL caching that would have to be maintained alongside the existing cache flush logic. The downside shows up with tags chosen too broadly: if a resolver only uses the generic tag catalog_product instead of specific product IDs, even the smallest price change on a single product invalidates the entire product cache, which drastically lowers GraphQL cache hit rates.
7. Query complexity and rate limiting as a precursor to caching
Working GraphQL caching does not automatically solve the problem of unbounded nested queries. Even if a client uses the same persisted query hash and is therefore theoretically cacheable, a deeply nested query with many linked objects (category with products with variants with images with related products) remains expensive to resolve on the first cache miss, and an aggressively low TTL or a high miss rate driven by many variants can regenerate that cost repeatedly. Query complexity has to be limited independently of caching, as a safeguard that prevents a single request from ever entering the expensive resolution path in the first place.
Magento configures these limits in app/etc/env.php under the graphql key, with the values max_query_complexity and max_query_depth. The former limits the total number of requested fields weighted by their resolution cost, the latter limits the maximum nesting depth of the query. If a request exceeds these limits, Magento rejects it with a GraphQL error before resolver execution even starts, regardless of whether it arrives as a persisted query or as a classic POST request.
Combined with persisted queries, this results in a clean two-layer defense: rate limiting and complexity limits prevent expensive or maliciously constructed queries from ever reaching the resolver layer, while GraphQL caching in Varnish answers the remaining, valid requests as often as possible without loading PHP-FPM again. Both measures complement each other, but neither replaces the other.
8. Pitfalls with personalized fields
The most dangerous mistake when building GraphQL caching is accidentally storing personalized data in a shared cache. Fields such as customerCart, customer, or a custom resolver for loyalty points, individual discounts or recently viewed products must never end up in a Varnish cache shared across all visitors, even if the same query also contains cacheable fields such as product data. A cache hit that serves another session's cart or customer data is not a performance optimization, it is a data leak.
The established pattern for separation: personalized fields are consistently moved into a separate query on the client side, one that never travels through the GET-plus-persisted-query path but always goes as a POST directly to PHP-FPM and is answered there with Cache-Control: no-store. The cacheable query (product data, category tree, CMS content) and the private query (cart, customer account) are issued by the frontend as two separate GraphQL requests, often in parallel, but never as a single combined query with mixed cache behavior.
At the resolver level, the same separation can be enforced by having a personalized resolver actively mark the entire response as non-cacheable the moment it executes. That prevents a developer from accidentally adding a personalized field to an otherwise cacheable page query and thereby rendering the whole GraphQL cache for that page useless, an effect that would not even be noticeable while testing with a logged-in account.
declare(strict_types=1);
namespace Mironsoft\CustomerGraphQl\Model\Resolver;
use Magento\Framework\Exception\LocalizedException;
use Magento\Framework\GraphQl\Config\Element\Field;
use Magento\Framework\GraphQl\Exception\GraphQlAuthorizationException;
use Magento\Framework\GraphQl\Query\Resolver\ContextInterface;
use Magento\Framework\GraphQl\Query\ResolverInterface;
use Magento\Framework\GraphQl\Schema\Type\ResolveInfo;
use Magento\GraphQlCache\Model\CacheableQuery;
use Mironsoft\Loyalty\Api\LoyaltyRepositoryInterface;
/**
* Resolver for customer-specific loyalty points, explicitly marked non-cacheable.
*/
final class CustomerLoyaltyPoints implements ResolverInterface
{
/**
* @param LoyaltyRepositoryInterface $loyaltyRepository Repository for per-customer loyalty balances.
*/
public function __construct(
private readonly LoyaltyRepositoryInterface $loyaltyRepository,
) {
}
/**
* @param Field $field GraphQL field configuration.
* @param ContextInterface $context Resolver execution context.
* @param ResolveInfo $info Resolve tree metadata.
* @param array|null $value Parent resolver value.
* @param array|null $args GraphQL field arguments.
* @return array Resolved loyalty point balance for the authenticated customer.
* @throws GraphQlAuthorizationException When no customer session is present.
* @throws LocalizedException On repository failure.
*/
public function resolve(Field $field, $context, ResolveInfo $info, array $value = null, array $args = null): array
{
if (!$context->getExtensionAttributes()->getIsCustomer()) {
throw new GraphQlAuthorizationException(__('This field requires a logged-in customer.'));
}
// Explicitly force the whole response to bypass the shared GraphQL cache.
// Without this call, a badly composed query could still let a cacheable
// sibling resolver make the personalized response end up in Varnish.
/** @var CacheableQuery $cacheableQuery */
$cacheableQuery = $context->getExtensionAttributes()->getCacheableQuery();
$cacheableQuery->setCacheValidity(false);
return [
'points' => $this->loyaltyRepository->getPointsForCustomer((int) $context->getUserId()),
];
}
}
9. Monitoring cache effectiveness
Without measurement, every GraphQL caching configuration remains a guess. The first indicator is the debug header X-Magento-Cache-Debug, set by Varnish in vcl_deliver, which reports HIT or MISS per response. This header is a poor fit for continuous monitoring because it shows individual requests without aggregation. More informative is evaluating Varnish's own statistics via varnishstat or varnishncsa, filtered on the /graphql path, to look at the actual hit rate for GraphQL separately from the hit rate for classic HTML pages.
In practice, the achievable hit rate differs sharply depending on the chosen caching strategy. A classic REST endpoint with a stable URL per resource traditionally reaches the highest hit rate because the cache key is already stable without extra measures. GraphQL without persisted queries achieves virtually no meaningful hit rate, because every query formatting variant produces a new cache key. Only GraphQL with persisted queries and a correctly configured Varnish reaches a hit rate comparable to REST again, while retaining greater flexibility on the client side.
| Criterion | REST endpoint caching | GraphQL without persisted queries | GraphQL with persisted queries + Varnish |
|---|---|---|---|
| Cache key stability | High, the URL is the key | Very low, body varies | High, the hash is the key |
| Hit rate in practice | 70-90% | Close to 0% | 60-85% |
| Implementation effort | Low | None (but ineffective) | Medium to high (VCL, client, IdentityInterface) |
| Personalization | Requires a separate endpoint | Not an issue, but no caching either | Requires clean query separation |
The table makes clear that the implementation effort for GraphQL caching with persisted queries is real, but it pays off in a hit rate that returns to the same order of magnitude as classic REST caches. Anyone who avoids this effort and runs GraphQL without persisted queries in production is effectively giving up the entire full-page caching benefit for GraphQL traffic, no matter how well Varnish is configured otherwise.
10. Summary
Reliable GraphQL caching in Varnish does not come from a single setting, it emerges from the interplay of several layers: persisted queries turn variable POST bodies into stable, hashable GET requests. The cache headers Cache-Control, X-Magento-Cache-Id and X-Magento-Tags communicate per response whether and how long it may be cached, and which tags a later invalidation should target. Custom resolvers actively join this system via IdentityInterface, and the Varnish VCL must explicitly fold store, currency and customer group into the hash key, so that caching GraphQL with Varnish does not end up serving wrong prices.
Invalidation follows the same tag-based PURGE mechanism as the classic Magento page cache, which creates consistency but also means that overly broad tags unnecessarily lower the hit rate. Query complexity limits and personalized fields with clean query separation are not optional extras, they are necessary safeguards without which GraphQL caching either lets expensive requests through unthrottled or lets private data end up in a shared cache.
GraphQL caching with Varnish: the essentials at a glance
Persisted queries
Query hash instead of query body as a GET request, the prerequisite for Varnish to form a stable cache key at all.
Cache headers
Cache-Control, X-Magento-Cache-Id and X-Magento-Tags control cacheability, key and invalidation per response.
IdentityInterface
Custom resolvers must actively declare cache tags, otherwise they stay outside the GraphQL caching system.
Personalized fields
Customer data and cart data belong in a separate, never-cached query, not in the same request as catalog data.
11. FAQ: GraphQL Caching with Varnish
1Why doesn't Varnish cache GraphQL automatically?
2What is a persisted query?
3Is GET instead of POST enough on its own?
4What is X-Magento-Tags good for?
5What if IdentityInterface is missing?
6How do I keep personalized data out of the cache?
7Why customer group in the hash key?
8Does caching solve unbounded nested queries?
9How do I invalidate on product updates?
10How do I check cache hits?
Mironsoft
GraphQL performance, Varnish and full-page caching for Magento storefronts
GraphQL caching that actually takes load off your storefront?
We analyze your GraphQL traffic patterns, set up persisted queries, and configure Varnish so that hit rate, invalidation and personalization work together instead of undermining each other.
Cache audit
Analysis of hit rate, cache headers and tag conventions in your existing GraphQL caching setup
Persisted queries
Rollout of persisted queries including VCL adjustments for stable cache keys
Invalidation
Clean IdentityInterface resolvers and tag conventions for precise purging