and Edge Caching the right way
GraphQL and caching are often seen as a difficult pair, because every request hits the same endpoint and CDNs do not cache POST requests by default. With the right combination of application-level resolver caching and edge caching through persisted queries, the problem can be solved systematically.
Table of Contents
- 1. Why GraphQL and caching are hard to combine
- 2. The two cache layers: resolver and edge
- 3. Resolver caching: strategy and cache key design
- 4. Edge caching with persisted queries
- 5. Cache invalidation: the hardest problem
- 6. Caching strategies in Magento GraphQL
- 7. Wrong vs. right in GraphQL caching
- 8. Typical failure patterns in combined caching
- 9. Caching strategies compared
- 10. Summary
- 11. FAQ
1. Why GraphQL and caching are hard to combine
The fundamental problem of GraphQL and HTTP caching lies in the transport layer: GraphQL uses HTTP POST by default, and POST requests are not cached by CDNs and proxies. Unlike REST, where every endpoint has its own URL and GET requests are cached without any trouble, all GraphQL requests land on the same endpoint (/graphql) with a different body. A CDN only sees a POST request to a URL and skips the cache configuration entirely.
That does not mean GraphQL cannot be cached, it means you have to actively build a caching strategy instead of relying on default behavior. The good news: there are two layers at which GraphQL can be cached effectively, and both complement each other well. The first layer is the resolver cache at the application level, which avoids expensive database queries within the same process. The second layer is the edge cache at the CDN, which caches complete GraphQL responses through HTTP caching, made possible by persisted queries as GET requests.
2. The two cache layers: resolver and edge
Resolver caching operates within a single GraphQL request. When several fields in a query would call the same resolver with the same arguments, or when different queries within the same request ask for similar data, an in-memory cache prevents the same database query from running multiple times. The best known approach is the DataLoader pattern, which batches and de-duplicates requests. Magento has similar mechanisms through cache pools and context-based caching.
Edge caching operates at the level of complete HTTP responses. When a CDN or proxy caches a GraphQL response, the next client with the identical request gets the cached response directly from the edge node, without the request ever reaching the application server. This lowers latency dramatically and reduces load on the server. The prerequisite: the request must be formulated as a GET request, which persisted queries make possible.
3. Resolver caching: strategy and cache key design
The cache key for a resolver must encode every parameter that influences the result: the resolver method, all arguments, the language, the customer context (anonymous vs. logged in) and, if relevant, the store context. A cache key that ignores the customer context is a serious security hole: data cached for an anonymous request could be served to a logged in customer. In Magento this is especially critical for prices, stock levels and personalized recommendations.
The TTL (time to live) for resolver caches should be differentiated by data type. Static content such as CMS pages or category text can have long TTLs measured in hours. Dynamic data such as prices or stock levels need short TTLs or must be invalidated based on events. The DataLoader pattern, originally developed for Node.js but conceptually implementable in PHP as well, bundles all resolver calls for the same data type within a request into a single batch database call.
# Query that benefits from both resolver-level and edge-level caching
# Resolver cache: prevents N+1 for category data
# Edge cache: entire response cached at CDN via persisted query GET
query CategoryPageData($categoryId: String!, $pageSize: Int = 20) {
categories(filters: { ids: { in: [$categoryId] } }) {
items {
name
description
products(pageSize: $pageSize) {
total_count
items {
sku
name
price_range {
minimum_price {
final_price { value currency }
}
}
}
}
}
}
}
4. Edge caching with persisted queries
Persisted queries solve the fundamental problem of POST caching: instead of sending the full query in the request body, the client registers the query once on the server (or uses predefined query hashes), and afterwards sends only the hash as a GET parameter. The server recognizes the hash, loads the associated query and executes it. The client now sends GET requests with a stable hash, and the CDN can treat these requests like normal HTTP GET requests, with all the standard caching mechanisms.
Implementing persisted queries in Magento requires server-side adjustments: an endpoint that accepts hashed queries, and a storage layer that keeps the query maps persistent. Apollo Client and other GraphQL clients support persisted queries out of the box. The most important configuration on the CDN side: Cache-Control headers must be set by the server for cacheable queries, and Vary headers must ensure that store context and language are treated as cache dimensions.
# Persisted Query pattern: client sends hash instead of full query
# GET /graphql?operationName=CategoryPage&extensions={"persistedQuery":{"sha256Hash":"abc123..."}}
# Server looks up query by hash and executes:
query CategoryPage($categoryId: String!) {
categories(filters: { ids: { in: [$categoryId] } }) {
items {
name
products(pageSize: 20) {
items { sku name }
}
}
}
}
# CDN caches the GET response with:
# Cache-Control: public, max-age=300, stale-while-revalidate=60
5. Cache invalidation: the hardest problem
Cache invalidation is not called one of the hardest problems in software development for nothing. With GraphQL it is especially complex because a single cache entry at the response level combines many different data sources. A single product price change can invalidate dozens of cached responses across different CDN nodes, at the resolver level and at the edge level at the same time. Anyone without a clear invalidation strategy ends up serving stale prices or stock levels.
Proven patterns for invalidation: tag-based caching, where every cached response is tagged with markers for the data it contains (for example product_1234, category_56). When data changes, all responses carrying the matching tag are invalidated. Varnish, Fastly and Cloudflare support tag-based purging through surrogate key headers. In Magento this pattern is already built in for Varnish, for GraphQL it must be explicitly extended to the cache tags of the queried entities.
# Cache tagging concept for GraphQL responses
# Server sets Surrogate-Key or Cache-Tag headers:
# Surrogate-Key: product_1234 product_5678 category_10
# On product price update, purge all tagged responses:
# PURGE /graphql?... with tag: product_1234
query ProductsWithCacheTags($skus: [String!]!) {
products(filter: { sku: { in: $skus } }) {
items {
id
sku
name
price_range {
minimum_price { final_price { value } }
}
}
}
}
6. Caching strategies in Magento GraphQL
In Magento 2 there is already a built-in GraphQL cache based on Varnish and the full page cache. For anonymous requests the result is cached, for authenticated requests the cache is bypassed. This distinction is correct in principle, but in many projects it is not fine-grained enough: category pages for logged in customers without price or stock personalization could partly be cached, but are not, because the auth header blanket-disables the cache.
The more advanced strategy for Magento: extract the public parts of queries, category text, product descriptions, images, from resolver responses and cache them separately, while personalized parts, prices for logged in customer groups, stock by warehouse assignment, get short TTLs or no caching at all. This requires architectural adjustments: instead of one large query, two smaller queries deliver public and personalized data separately. The public query is cached, the personalized one is not.
7. Wrong vs. right in GraphQL caching
The most common mistake in GraphQL caching: disabling the cache for all GraphQL requests because a single personalized query marks the entire endpoint as non-cacheable. That is unnecessarily restrictive. With a clean separation between public and personalized queries and a caching strategy that operates at query granularity instead of endpoint granularity, significant cache hit rates can be achieved even in projects with logged in users.
| Scenario | Wrong | Right | Effect |
|---|---|---|---|
| Edge caching | POST without persisted queries | GET with persisted query hash | CDN can cache the response |
| Cache keys | Without context parameters | Store + language + auth status | No data leaks between contexts |
| Invalidation | TTL only, no tag purging | Tag-based purging | Immediate consistency after a change |
| Personalization | Everything uncached on auth | Separate public / private queries | High hit rate even for logged in users |
| N+1 in the resolver | One DB query per entity | DataLoader / batch loading | Significantly fewer DB queries per request |
8. Typical failure patterns in combined caching
The most common failure pattern in GraphQL caching: stale prices after price changes, because the invalidation strategy never set the price entities as cache tags. The administrator changes a price in the backend, but the storefront keeps showing the old price for an hour, because the edge cache still holds the old value and never received a purge signal. This problem is easier to solve in REST projects with clearly defined resource URLs than in GraphQL, where a single response can mix many entities.
A second failure pattern: cache poisoning caused by missing Vary headers. When the CDN does not distinguish between different store views or languages and serves a German cache entry to English speaking visitors, a subtle bug appears that is hard to reproduce in tests. The third common mistake: a resolver cache without a size limit, which grows without bound and exhausts the application server's memory. Every in-memory cache needs a configured maximum size and an eviction policy.
9. Caching strategies compared
Choosing the right caching combination depends on the use case. A headless shop with mostly anonymous visitors benefits most from edge caching through persisted queries, most responses can be cached with long TTLs. A shop with many logged in customers and personalized prices benefits more from resolver caching with short TTLs, which reduces database queries within a request without mixing personalization data.
Combining resolver and edge caching, the essentials at a glance
Resolver cache
DataLoader/batch loading prevents N+1 within a request. The cache key must include store, language and auth status.
Edge cache
Persisted queries convert POST to GET. The CDN caches complete responses. Configure Cache-Control and Vary headers correctly.
Invalidation
Tag-based purging instead of TTL only. Entity tags in the response header. Trigger a purge event on data changes.
Personalization
Split public and personalized data into separate queries. Cache the public one, not the personalized one, or with a short TTL.
10. Summary
Combining resolver caching and edge caching is not a single feature, it is an architectural decision that has to feed into the query design from the very start. Anyone who treats caching as an afterthought added after launch fights against structural problems: queries that mix public and private data, missing cache tags in resolver outputs and missing persisted query infrastructure.
The most effective order: first introduce resolver caching with the DataLoader pattern and a clearly defined cache key schema, then implement persisted queries, and finally build edge caching with tag-based invalidation. Each layer brings measurable improvements on its own, and all three together make GraphQL APIs competitive with cached REST APIs, without giving up the flexibility of GraphQL.