Redis Caching Strategies for Magento GraphQL: More Granular Than Full Page Cache
AI generated
SET
TTL
Redis / Magento GraphQL
Redis Caching Strategies for Magento GraphQL
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.

12 min read GraphQL query cache Cache key design Store-view & customer-group aware

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.

11. FAQ: Redis GraphQL Caching in Magento: The Essentials at a Glance

1Why isn't classic full page caching enough for Magento GraphQL?
Every request goes through the same endpoint, and only the query string in the request body determines the response, so a URL-based caching strategy no longer applies.
2Doesn't Magento GraphQL already cache responses through the built-in FPC?
For resolvers marked cacheable and free of personalization, Magento already sets Cache-Control and X-Magento-Tags headers that Varnish or the Redis-backed FPC can use.
3When does a custom query-level caching layer still pay off?
Mainly for headless setups without an upfront FPC, or for composite queries with mixed cacheability across resolvers within a single request.
4How is a deterministic cache key built from a GraphQL query?
The query is normalized and canonically serialized first, then combined with sorted variables plus store ID and currency into a hash.
5How are personalized fields handled when caching?
An allowlist ensures only resolvers explicitly marked as public are cached, while personalized resolvers with customer-specific data stay out of the cache by default.
6Which tags are used for invalidation?
The same granular tags used for classic FPC, such as product or category IDs, extended with additional tags for composite resolver results.
7What is a cache stampede in the GraphQL context?
When a heavily used cache entry expires and many parallel requests simultaneously trigger the same expensive resolver instead of waiting on an already running computation.
8How can a cache stampede be avoided?
Through probabilistic early expiration, or a short-lived Redis lock via SET NX PX that lets only one process recompute the value while others briefly wait.
9How do you monitor the effectiveness of the GraphQL cache layer?
Through separate hit and miss counters per resolver type, plus regular checks of key cardinality within the GraphQL namespace using redis-cli SCAN.
10For which Magento setups does the effort pay off best?
For headless frontends with high, recurring GraphQL traffic and a limited, predictable query repertoire, less so for highly heterogeneous, dynamically generated queries.