Caching in GraphQL: HTTP, Persisted Queries, Response Cache, Edge Caching
AI generated
{ }
type
GraphQL · Caching · Persisted Queries · CDN · Magento Performance
Caching in GraphQL
HTTP, Persisted Queries, Response Cache, Edge Caching

GraphQL sends queries as a POST request to a single endpoint, which makes standard HTTP caching harder than in REST. Anyone who takes GraphQL performance seriously needs to understand every caching layer: Persisted Queries for HTTP caching, resolver-level response cache, and CDN edge caching for non-personalized content.

16 min read Persisted Queries · Response Cache · Redis · Varnish · CDN Edge GraphQL · Magento · Headless Commerce

1. Why caching in GraphQL is more complex than in REST

In REST APIs, HTTP caching is straightforward: GET requests for /products/42 can be cached by a CDN, the browser and proxies. The URL is the cache key. In GraphQL, on the other hand, every request is a POST request to the same endpoint (/graphql), and POST requests are by definition not cached. That means every query, whether for product lists, categories or CMS pages, passes through every caching layer uncached and hits the application server directly. Under high traffic, that is a significant performance disadvantage.

Then there is the granularity problem: a single GraphQL query can merge data from different backend systems with different cache lifetimes. A query that simultaneously fetches static CMS content (cache TTL: hours) and dynamic stock data (cache TTL: minutes) cannot be represented with a single HTTP cache header. Effective caching in GraphQL therefore requires a multi-layered approach, tuned to the characteristics of each individual field and resolver.

2. HTTP caching and the POST problem

The POST problem is the central starting point for every GraphQL caching strategy. While GET /products?search=bag is cached without issue by CDNs and browser caches, POST /graphql with the query body always lands on the application server. There are two ways to work around this. The first: queries can also be sent as a GET request with the query string URL-encoded (GET /graphql?query={products{items{sku}}}). This works for short queries, but runs into URL length limits for complex ones.

The second and more robust approach is Persisted Queries: the query is registered on the server in advance under a hash ID. The client then sends only GET /graphql?hash=abc123, a short, cacheable GET request. Both methods can enable HTTP caching in principle, but only Persisted Queries scale reliably for production GraphQL APIs. The key is that the cache key is stable and predictable, which is harder to guarantee with dynamically generated query strings.

3. Persisted Queries: enabling HTTP caching for GraphQL

Persisted Queries are a two-stage mechanism. In the first phase, the frontend build system or a bootstrap script registers every known query with the server. Each query receives a deterministic hash (usually a SHA256 of the query string). In the second phase, the client sends only the hash via a GET request, and the server looks up the query by hash in its registry and executes it. Because the request is now a GET with a stable hash as a parameter, a CDN, Varnish or Nginx can cache it normally.

The practical benefit goes beyond caching: since the server now only accepts registered queries, arbitrary or manipulated query bodies from the client can be rejected in production. That is a significant security gain. Apollo Client, Relay and other GraphQL clients support Persisted Queries natively. For Magento headless frontends built with Next.js or Nuxt, query registration can be integrated into the CI pipeline as a build step, so new queries are automatically registered with the server before the frontend is deployed.


# Persisted Query workflow

# Step 1 (build time): Register query with hash
# POST /graphql
# { "query": "query ProductList { products(search: \"bag\") { items { sku name } } }",
#   "extensions": { "persistedQuery": { "version": 1, "sha256Hash": "abc123..." } } }

# Step 2 (runtime): Client sends only the hash via GET
# GET /graphql?extensions={"persistedQuery":{"version":1,"sha256Hash":"abc123..."}}
# → Server looks up query by hash, executes, returns result
# → CDN/Varnish can cache GET responses by hash + query variables

# Step 3 (CDN caching): Only for public, non-personalized data
# Cache-Control: public, max-age=300 (5 minutes for product data)
# Vary: Accept-Language, Store-Code (vary by store view)

# For authenticated/personalized queries: use response cache at resolver level
# Never edge-cache responses that contain customer-specific data

4. Response cache at the resolver level

The response cache at the resolver level is the second caching layer, and it works independently of the HTTP protocol. It is especially useful for expensive resolver operations that produce the same output for the same inputs, regardless of whether the request arrives as GET or POST. The pattern: the resolver checks an in-memory cache (Redis, Memcached) for a cache key computed from the resolver arguments and optional context data. On a cache hit, the stored result is returned immediately. On a cache miss, the actual operation runs and the result is written to the cache with a TTL.

Cache key design is critical here. For public data such as product lists or categories, a key built from the query arguments (search term, filters, sorting) is enough. For store-view-dependent data, the store code must be part of the key. For personalized data, no response cache should be used at all, since the performance gain from caching rarely justifies the added complexity, and privacy problems arise if personalized data is accidentally shared.

5. Redis as a caching backend: patterns and configuration

Redis is the standard caching backend for production GraphQL APIs and for Magento. For GraphQL response caching, string keys with JSON-serialized values and a TTL expiry work best. The pattern: the cache key contains a prefix (e.g. gql:product:), the hash ID of the query, and a hash of the arguments. This prevents key collisions between different query types and allows targeted invalidation via key prefixes.

For tag-based invalidation, the most important pattern for precise cache clearing, each cache entry additionally stores its associated cache tags in a set. When a product is updated, you iterate over all sets that contain the product tag and delete the associated cache keys. This pattern is already established in Magento for Varnish through its cache-tag system, and it can be implemented analogously for Redis-backed GraphQL response caches.

6. Edge caching with a CDN and Varnish

Edge caching via a CDN or Varnish is the most powerful caching layer, because it intercepts requests before they ever reach the application server. For GraphQL, this only works with Persisted Queries sent as a GET request. Configuration at the CDN then mirrors REST: Cache-Control headers govern TTL and Vary parameters. It is important that only non-personalized, public data belongs in the edge cache. Product lists, category pages, CMS blocks and static configuration data are good candidates.

The critical pitfall with edge caching: if a logged-in customer sends a query with the same hash ID as a public query, but carries a token in the Authorization header, the edge cache must not return the public version. The Vary: Authorization header pattern ensures that requests with and without a token are treated as separate cache keys. Varnish configurations for Magento already use this principle for traditional HTTP requests and need to be extended accordingly for GraphQL GET requests.

7. Cache invalidation: tag-based and precise

The well-known saying applies to GraphQL too: cache invalidation is one of the hardest problems in computer science. Overly aggressive invalidation (clearing everything on every change) eliminates the caching benefit. Overly conservative invalidation (caching content for too long) leads to stale data in the frontend. The tag-based invalidation pattern is the best compromise: every cache entry is tagged with information describing which data it contains.

In Magento, cache tags are already an established concept: products carry tags like cat_p_42, categories carry cat_c_7. These tags are collected via observer events on updates and invalidated in a targeted way. The same tag system should be used for GraphQL response cache entries: a resolver that returns product data annotates the cache entry with the product IDs of all returned products. When product 42 is updated, only cache entries tagged cat_p_42 are invalidated, not the entire cache.


# Cache-tag-based invalidation concept for GraphQL resolvers

# Query that fetches products, resolver annotates cache entry with product IDs
query CategoryProducts {
  categoryList(filters: { ids: { in: ["3"] } }) {
    name
    products(pageSize: 24, currentPage: 1) {
      items {
        id
        sku
        name
        price_range {
          minimum_price {
            final_price { value currency }
          }
        }
      }
    }
  }
}

# Resolver pseudo-code for cache with tags:
# cacheKey = "gql:cat:3:page:1:sort:default"
# cacheTags = ["cat_c_3", "cat_p_101", "cat_p_102", "cat_p_103", ...]
# store in Redis: SET gqlcache:{cacheKey} {json} EX 300
# store tag index: SADD gqltag:cat_p_101 gqlcache:{cacheKey}
#
# On product 101 update event:
# SMEMBERS gqltag:cat_p_101 → [gqlcache:{cacheKey}, ...]
# DEL gqlcache:{cacheKey} (targeted invalidation, not full flush)

8. Caching in Magento GraphQL: practice and pitfalls

Magento has its own response-caching system for GraphQL, controlled via the X-Magento-Cache-Id header. Non-personalized requests receive a deterministic cache key derived from store view, currency and customer group (guest). Personalized requests, those with an Authorization token, are not cached. This system works out of the box for standard queries, but can lead to stale results with custom resolvers that pull in external data.

The most common pitfall in Magento: a custom resolver loads data from an external CRM or ERP. Magento's response cache knows nothing about this external data and cannot invalidate it in a targeted way. The solution: for external data, either extend the cache key (calibrating the TTL to the update frequency of the external source) or disable the response cache for these specific queries. It is important to document these decisions explicitly, otherwise you end up with hard-to-reproduce cache bugs where data looks correct in the frontend but is actually hours out of date.

9. Caching layers compared

Each caching layer has its own characteristics, use case and limitations. A well-thought-out GraphQL caching strategy typically combines several layers, not all at once, but selectively, based on what each individual query requires.

Caching Layer Mechanism Suited For Limitation
HTTP / Browser Cache GET + Cache-Control headers Static, public content Only with Persisted Queries via GET
CDN / Edge Cache Varnish, Cloudflare, Fastly Product lists, categories, CMS Never for personalized data
Magento GraphQL Cache X-Magento-Cache-Id header Standard queries without custom resolvers No control over external data
Redis Response Cache Resolver-level, tag-based Expensive resolvers, custom data Implementation effort
In-Memory (PHP Request) Array cache within one request N+1 avoidance within the same request Only lasts for the duration of a request

The optimal strategy for a Magento headless application: Persisted Queries for all public, non-personalized queries, which can then be cached via a CDN. Magento's built-in GraphQL response cache for standard queries. A Redis-backed custom cache for your own resolvers with external data sources. An in-memory collector for N+1 batching within a single request. Personalized queries are not cached at all, here resolver optimization and N+1 avoidance are what matter.

Mironsoft

GraphQL performance, caching architecture and Magento optimization

GraphQL APIs that keep performing under peak traffic?

We analyze existing GraphQL setups for caching gaps, implement Persisted Queries, configure Redis response caches, and set up tag-based invalidation for Magento.

Persisted Queries

Implementation and build-step integration for HTTP caching via CDN

Redis Response Cache

Tag-based cache for custom resolvers with precise invalidation

CDN Configuration

Configure and test Varnish and CDN for GraphQL GET requests

10. Summary

Caching in GraphQL requires a multi-layered approach, because POST requests are not cached by default and different fields have different caching characteristics. Persisted Queries solve the POST problem by enabling HTTP caching via GET requests. Resolver-level response cache with Redis speeds up expensive operations regardless of protocol. Edge caching with a CDN or Varnish eliminates backend requests entirely for public, non-personalized content. Tag-based invalidation ensures that cached data can be updated precisely, without a global cache clear.

In Magento projects, that means concretely: integrate Persisted Queries into the frontend build step, use Magento's built-in GraphQL cache for standard queries, and equip custom resolvers with a Redis cache and tag-based invalidation. The most important principle: personalized data never belongs in the edge cache. For everything else, the rule is: the further forward in the stack you cache, the better the performance for every user.

Caching in GraphQL, the essentials at a glance

Persisted Queries

Hash-registered queries enable GET requests. CDN and browser can cache GraphQL responses for public content. Security gain as a side effect.

Response Cache

Resolver-level cache with Redis. Cache key built from query arguments plus store code. Calibrate TTL to the update frequency of the data.

Tag-Based Invalidation

Tag cache entries with product/category tags. On update, delete only affected entries in a targeted way. No global cache clear.

Personalization

Personalized queries never in the edge cache. Here N+1 avoidance and resolver optimization matter. Vary: Authorization protects the edge cache against data leaks.

11. FAQ: Caching in GraphQL

1Why is caching in GraphQL harder?
Every query goes as a POST request to the same endpoint. POST is not cached. Persisted Queries solve this via GET with a stable hash ID.
2What are Persisted Queries?
Queries registered server-side with a hash ID. The client sends only the hash via GET. Enables HTTP caching and prevents arbitrary client queries in production.
3Response cache at the resolver level?
A Redis cache managed by the resolver itself. On a cache hit: immediate return. On a cache miss: run the operation, cache the result with a TTL.
4Personalized data in the edge cache?
No. Vary: Authorization ensures separate entries. When in doubt, don't cache rather than risk a data leak.
5What is tag-based invalidation?
Cache entries receive tags (product IDs). On update, only affected entries are deleted in a targeted way. No global cache clear needed.
6Magento's built-in GraphQL caching?
X-Magento-Cache-Id header. Deterministic key for guest requests. Requests with a token are not cached. Custom resolvers with external data can cause problems.
7When to avoid a response cache?
For real-time data (stock levels), personalized data, and resolvers with external sources that produce no invalidation events.
8Good candidates for edge caching?
Product lists, category pages, CMS blocks, static configuration, anything identical for every guest user. Persisted Queries via GET are a prerequisite.
9Persisted Queries in Next.js plus Magento?
Build step in CI: extract queries, hash them, register with the server. At runtime the Apollo client sends only the hash via GET. Next.js uses ISR or SWR caching.
10Most important caching rule of thumb?
Don't treat all queries the same. Public: edge cache. Expensive custom resolvers: Redis. Personalized: no cache, resolver optimization instead.

Effective GraphQL caching requires several layers working together, from HTTP through Redis to the CDN edge, and it must always be planned together with the invalidation strategy to avoid stale-data problems and unexplained cache misses.

Persisted Queries are the decisive enabler here: they turn POST-only endpoints into cacheable GET requests and give Varnish, Fastly or Cloudflare the ability to accelerate GraphQL responses at the edge.