more granular than classic full page caching
Magento's built-in full page cache relies on unique URLs and HTML responses that get cached as a single unit and invalidated via tags. GraphQL breaks that model: a single POST endpoint, freely combinable query strings, variables, and partly personalized fields produce a wide range of different responses that can no longer be identified cleanly by a URL. Anyone running Magento headless or as an API backend for a custom frontend needs a dedicated caching layer on the query level instead. Redis is particularly well suited for this, because query results can be stored there as granular individual keys, tagged precisely, and invalidated selectively without flushing the entire cache.
Table of Contents
- 1. Why classic full page caching falls short for GraphQL
- 2. The existing FPC mechanism for cacheable GraphQL responses
- 3. Building granular query-level caching directly in Redis
- 4. Cache key generation for variable query parameters
- 5. Invalidation strategy for GraphQL-specific data changes
- 6. Handling personalized fields inside composite queries
- 7. TTL design and avoiding cache stampedes on expensive queries
- 8. Monitoring: keeping an eye on hit rate and key cardinality
- 9. Limits and pitfalls of a custom GraphQL caching layer
- 10. Summary
- 11. FAQ
1. Why classic full page caching falls short for GraphQL
Magento's full page cache is built on the idea that a URL corresponds to a unique page, and the complete HTML response gets cached as one connected block. GraphQL removes that one-to-one relationship: every request goes through the same /graphql endpoint, and only the body of the POST request, the query string plus variables, determines what data is actually returned. Two requests to the same URL can therefore produce completely different responses, which means a URL-based caching strategy has nothing to grab onto.
Composite queries make the problem worse: a single GraphQL request can call several resolvers at once, combining product data, category tree, and customer information in one response. Caching the entire response as a block would either accidentally cache personalized data or, out of caution, cache nothing at all and waste performance potential. This is exactly where a granular, Redis-based caching layer on the query level comes in, treating individual response parts independently of each other.
2. The existing FPC mechanism for cacheable GraphQL responses
Magento already ships a built-in mechanism that covers certain GraphQL responses through the regular full page cache: for resolvers marked as cacheable and free of personalized fields, Magento sets the headers Cache-Control: public, X-Magento-Tags, and a computed X-Magento-Cache-Id that factors in store, currency, and other context. When Varnish or the built-in, Redis-backed FPC sits in front, the full JSON response is treated like an HTML page and stored under that cache key.
This mechanism works well for classic catalog queries, but hits limits as soon as a query combines several resolvers with mixed cacheability, or when Magento runs headless without an upfront FPC, for instance when a dedicated Node.js or React backend talks directly to the GraphQL API. In such cases, the only option left is a custom, more granular caching layer that operates on the query and field level independently of HTTP response caching.
3. Building granular query-level caching directly in Redis
Instead of caching the entire HTTP response, a query-granular strategy caches the result of individual, clearly scoped resolver calls, such as a product lookup by SKU or a category tree query by category ID. Each such call gets its own Redis key holding only the serialized partial result, not the whole composite response. That makes it possible to assemble a composite query from several independently cached building blocks, some served from cache and others computed fresh.
Technically this can be implemented as a dedicated data loader or as a plugin on the relevant resolver classes: before the actual data fetch, the code first checks Redis for a matching key, and only on a miss does the expensive resolver code, often involving several database queries, actually run, with the result cached afterward under a sensible TTL.
<?php
declare(strict_types=1);
namespace Mironsoft\GraphQlCache\Plugin;
use Magento\Framework\App\CacheInterface;
use Magento\Framework\Serialize\SerializerInterface;
/**
* Caches a resolver's partial result independently of the full
* HTTP response, on the query level, in Redis.
*/
class CacheResolverResult
{
/**
* @param CacheInterface $cache Magento cache frontend, Redis-backed.
* @param SerializerInterface $serializer To (de)serialize the partial result.
*/
public function __construct(
private readonly CacheInterface $cache,
private readonly SerializerInterface $serializer,
) {
}
/**
* Reads a partial result from cache, or computes and stores it anew.
*
* @param string $cacheKey Deterministic key derived from the query fingerprint.
* @param callable $resolveCallback Produces the result on a cache miss.
* @param array $tags Cache tags for targeted invalidation.
* @return array
*/
public function resolveWithCache(string $cacheKey, callable $resolveCallback, array $tags): array
{
$cached = $this->cache->load($cacheKey);
if ($cached !== false) {
return $this->serializer->unserialize($cached);
}
$result = $resolveCallback();
$this->cache->save($this->serializer->serialize($result), $cacheKey, $tags, 3600);
return $result;
}
}
4. Cache key generation for variable query parameters
The critical point of any GraphQL caching strategy is deriving a deterministic cache key from query, variables, and context. Two syntactically different but semantically identical queries, for instance with reordered fields or extra whitespace, must map to the same key, otherwise the cache fills up with redundant entries for what is effectively the same request. A proven approach normalizes the query first, parsing it and re-serializing the abstract syntax tree in canonical form before hashing it.
In addition to the query fingerprint, the key must include store view, currency, and, where relevant, the customer group ID, since the same query can return different prices or visibility depending on context. Customer-specific variables such as a customer ID, however, deliberately do not belong in the standard key. Instead, as described in the next section, they are either handled separately or excluded from caching entirely.
<?php
declare(strict_types=1);
/**
* Builds a deterministic cache key from query, variables, and
* context factors such as store and currency.
*
* @param string $normalizedQuery Canonically serialized, normalized query.
* @param array $variables GraphQL variables of the request.
* @param int $storeId Current store view ID.
* @param string $currencyCode Current currency code.
* @return string
*/
function buildGraphQlCacheKey(
string $normalizedQuery,
array $variables,
int $storeId,
string $currencyCode
): string {
ksort($variables);
$fingerprint = hash('sha256', $normalizedQuery . json_encode($variables));
return sprintf('gql_%d_%s_%s', $storeId, $currencyCode, $fingerprint);
}
5. Invalidation strategy for GraphQL-specific data changes
Because a single Redis key often bundles several composite resolver results, pure TTL-based expiration rarely suffices on its own. Instead, every cache entry should carry the same granular tags Magento already uses for classic FPC, such as cat_p_123 for a specific product or cat_c_45 for a category, extended with GraphQL-specific tags for composite resolvers that query several entities at once, such as a product listing query with filter facets.
When a product or category is saved, Magento's existing indexer and cache invalidation logic already fires the matching tags. An additional observer on catalog_product_save_after or a comparable event can forward those tags to the custom GraphQL cache layer, so that only affected Redis keys are removed while unchanged query results for other products keep being served from cache.
6. Handling personalized fields inside composite queries
As soon as a query contains personal fields, such as the cart, saved addresses, or an individual customer's price rules, the affected partial result must never end up under a global, customer-independent key. A robust strategy splits resolvers into two categories: public, store-wide resolvers whose result is identical for every visitor and can be cached aggressively, and personalized resolvers that are either excluded from caching entirely or stored under a customer-specific, short-lived key.
In practice, an allowlist works better than a blocklist: only resolvers explicitly marked as cacheable ever end up in Redis at all. Everything else is resolved fresh on every request. This conservative approach reliably prevents an overlooked field from accidentally serving one customer's personal data to another.
7. TTL design and avoiding cache stampedes on expensive queries
Very expensive, compute-heavy queries, such as complex faceted searches with many filters, should get a longer TTL than simple point lookups, since recomputing them generates significantly more database and CPU load. If such a key expires while many parallel requests are in flight, a cache stampede can occur, where numerous requests suddenly trigger the same expensive resolver simultaneously because the cache entry expired at exactly the same moment.
A simple, effective safeguard is probabilistic early expiration, where a small random lead time before actual expiry triggers a single recomputation while every other parallel request still gets the old, still-valid value. Alternatively, SET key value NX PX can set a short-lived lock that prevents multiple processes from running the same expensive resolver concurrently, while the remaining requests briefly wait for the first result.
8. Monitoring: keeping an eye on hit rate and key cardinality
Without monitoring, a GraphQL cache layer stays a black-box experiment. A dedicated counter per resolver type, tracking cache hits and misses separately, makes it easy to see which query patterns actually benefit from the caching layer and which practically always produce a miss for lack of reuse. A persistently low hit rate for a given resolver is a clear signal that either the cache key is generated too specifically, or the query in question is rarely repeated in practice.
Alongside that, it is worth watching raw key counts within the GraphQL namespace via redis-cli --scan --pattern 'gql_*' | wc -l, to catch uncontrolled cardinality growth early, for instance when unnormalized variables effectively produce a brand-new, never-reused key for every single request.
# Count GraphQL cache keys within the namespace
redis-cli --scan --pattern 'gql_*' | wc -l
# Roughly estimate memory usage of the GraphQL cache namespace
redis-cli --scan --pattern 'gql_*' | while read -r key; do
redis-cli memory usage "$key"
done | awk '{sum+=$1} END {print sum, "bytes"}'
9. Limits and pitfalls of a custom GraphQL caching layer
A self-built query-level caching layer is additional code that has to be maintained, tested, and checked for compatibility with the resolver schema on every Magento upgrade. If a resolver's internal structure changes, for instance through a new required field in the schema, the cache key generation and invalidation logic must be updated accordingly, or stale or inconsistent responses become a real risk.
The effort also pays off best for recurring, similar queries with high traffic, such as a storefront frontend with a limited, well-predictable query repertoire. For very heterogeneous, dynamically generated queries, as some GraphQL client libraries produce through automatic query composition, hit rates often stay low, and the extra Redis round trip can even slightly increase latency compared to a direct resolver call.
| Aspect | Classic FPC | Redis query-level caching | Practical relevance |
|---|---|---|---|
| Cache unit | Complete HTML page per URL | Individual resolver partial result | Finer granularity for GraphQL |
| Cache key | URL plus X-Magento-Vary | Query fingerprint plus store/currency | Needs custom normalization |
| Invalidation | Global tags per entity | Same tags, but per partial result | Less over-invalidation |
| Personalization | Vary header per segment | Explicit allowlist of cacheable resolvers | Higher error risk without allowlist |
| Suited for | Classic page views | Headless frontends with high GraphQL traffic | Extra effort only when truly needed |
Mironsoft
Cache layer setup and Magento Redis integration
Magento cache that isn't quite working or is misconfigured?
We set up Redis as a cache and session backend for Magento cleanly, tune memory usage and eviction strategies, and make sure full page cache and session storage work together reliably.
Redis Setup
Configure the cache, session, and FPC backend production-ready for Magento.
Memory Tuning
Match memory usage and eviction policies to the shop's actual load.
High Availability Setup
Set up Redis Sentinel or Cluster for resilient Magento environments.
10. Summary
Redis GraphQL Caching in Magento: The Essentials at a Glance
Starting problem
A single GraphQL endpoint with variable query strings cannot be covered by URL-based full page caching.
Solution approach
Individual resolver partial results are cached granularly in Redis, with deterministic keys built from normalized query, variables, and store context.
Invalidation
The same tags used for classic FPC, extended with GraphQL-specific tags for composite resolvers, allow targeted deletion instead of a global flush.
Limits
Pays off mainly for recurring queries with high traffic, but adds maintenance effort and error risk around personalized fields.