Skip to main content

Caching GraphQL: The Hardest Easy Problem You'll Ever Solve

· 8 min read
GraphQL Guy

GraphQL Caching

"Just add caching" they said. "It'll be easy" they said. Three weeks later, I emerged from my cave with bloodshot eyes and a newfound respect for cache invalidation. This is my story.

The Caching Paradox

GraphQL and caching have a complicated relationship. On one hand, GraphQL's flexibility means clients can request exactly what they need. On the other hand, that same flexibility makes caching incredibly hard.

With REST:

GET /users/123 → Cache key: "/users/123"

With GraphQL:

# Query A
{ user(id: "123") { name } }

# Query B
{ user(id: "123") { name email } }

# Query C
{ user(id: "123") { name email posts { title } } }

Three different queries. Three different cache keys? Or one? When user 123 updates their email, which caches need invalidation?

Welcome to the hardest easy problem in GraphQL.

The Seven Levels of GraphQL Caching

Like a video game, caching has levels. Each level is harder - and more rewarding - than the last.

Level 1: HTTP Caching (Boss: CDN)

The simplest approach: treat GraphQL like REST.

Client → CDN → GraphQL Server

The Problem: GraphQL uses POST requests. CDNs don't cache POST by default.

The Workaround: GET requests for queries:

# URL-encoded query
GET /graphql?query={user(id:"123"){name}}

Or use persisted queries:

# Query hash instead of full query
GET /graphql?extensions={"persistedQuery":{"sha256Hash":"abc123"}}

Spring Configuration:

@Configuration
public class HttpCacheConfig {

@Bean
public WebGraphQlInterceptor cacheControlInterceptor() {
return (request, chain) -> chain.next(request)
.map(response -> {
if (isQuery(request) && response.getErrors().isEmpty()) {
response.getResponseHeaders()
.setCacheControl(CacheControl.maxAge(60, TimeUnit.SECONDS).cachePublic());
}
return response;
});
}
}

Effectiveness:

┌──────────────────────────────────────────────────────────────┐
│ HTTP Caching Effectiveness │
├──────────────────────────────────────────────────────────────┤
│ │
│ Cache hits for identical queries: HIGH ████████████ │
│ Cache hits for different field sets: ZERO │
│ Invalidation precision: LOW ██ │
│ Implementation complexity: LOW ██ │
│ │
└──────────────────────────────────────────────────────────────┘

Level 2: Response Caching (Boss: Stale Data)

Cache the entire GraphQL response server-side.

@Component
public class ResponseCacheInterceptor implements WebGraphQlInterceptor {

private final Cache<String, String> cache = Caffeine.newBuilder()
.maximumSize(10_000)
.expireAfterWrite(Duration.ofMinutes(5))
.build();

@Override
public Mono<WebGraphQlResponse> intercept(WebGraphQlRequest request, Chain chain) {
if (!isQuery(request)) {
return chain.next(request);
}

String cacheKey = buildCacheKey(request);
String cached = cache.getIfPresent(cacheKey);

if (cached != null) {
return Mono.just(deserializeResponse(cached));
}

return chain.next(request)
.doOnSuccess(response -> {
if (response.getErrors().isEmpty()) {
cache.put(cacheKey, serializeResponse(response));
}
});
}

private String buildCacheKey(WebGraphQlRequest request) {
return DigestUtils.sha256Hex(
request.getDocument() +
request.getVariables().toString() +
request.getOperationName()
);
}
}

The Problem: User A caches a query. User B runs the same query but should see different data (permissions, personalization).

The Fix: Include user context in cache key:

private String buildCacheKey(WebGraphQlRequest request) {
String userId = getCurrentUserId();
String userRole = getCurrentUserRole();

return DigestUtils.sha256Hex(
request.getDocument() +
request.getVariables() +
userId + userRole // User-specific cache
);
}

Now you have N copies of each cache entry, where N is your user count. Your cache hit rate just dropped to nearly zero.

Level 3: Field-Level Caching (Boss: Complexity)

Cache individual field resolutions, not entire responses.

@SchemaMapping
@Cacheable(value = "products", key = "#product.id")
public Inventory inventory(Product product) {
return inventoryService.getInventory(product.getId());
}

The Magic: Different queries share cached field values:

# Query A
{ product(id: "1") { name inventory { stock } } }
# Caches: product:1:name, product:1:inventory

# Query B
{ product(id: "1") { inventory { stock } description } }
# Hits: product:1:inventory
# Caches: product:1:description

Implementation with DataLoader + Cache:

@Component
public class CachedInventoryLoader implements BatchLoaderWithContext<String, Inventory> {

private final Cache<String, Inventory> cache;
private final InventoryService inventoryService;

@Override
public CompletionStage<List<Inventory>> load(List<String> productIds,
BatchLoaderEnvironment env) {
// Check cache first
Map<String, Inventory> cached = new HashMap<>();
List<String> uncachedIds = new ArrayList<>();

for (String id : productIds) {
Inventory inv = cache.getIfPresent(id);
if (inv != null) {
cached.put(id, inv);
} else {
uncachedIds.add(id);
}
}

// Fetch uncached
if (!uncachedIds.isEmpty()) {
Map<String, Inventory> fresh = inventoryService.getInventories(uncachedIds);
fresh.forEach((id, inv) -> cache.put(id, inv));
cached.putAll(fresh);
}

// Return in order
return CompletableFuture.completedFuture(
productIds.stream().map(cached::get).toList()
);
}
}

Level 4: Normalized Caching (Boss: Client State)

This is where the big boys play. Normalize data by type + ID.

Server-Side Normalized Cache:

@Component
public class NormalizedCache {

// Key: "User:123", "Product:456"
private final Cache<String, Map<String, Object>> entities;

public void store(String typename, String id, Map<String, Object> fields) {
String key = typename + ":" + id;
Map<String, Object> existing = entities.getIfPresent(key);

if (existing != null) {
existing.putAll(fields); // Merge new fields
} else {
entities.put(key, new HashMap<>(fields));
}
}

public Map<String, Object> get(String typename, String id, Set<String> fields) {
String key = typename + ":" + id;
Map<String, Object> entity = entities.getIfPresent(key);

if (entity == null) return null;

// Check if we have all requested fields
if (!entity.keySet().containsAll(fields)) {
return null; // Cache miss - need to fetch missing fields
}

return filterFields(entity, fields);
}
}

Client-Side with Apollo:

const cache = new InMemoryCache({
typePolicies: {
Product: {
keyFields: ["id"],
fields: {
inventory: {
read(existing) {
return existing; // Return cached value
},
merge(existing, incoming) {
return { ...existing, ...incoming }; // Merge updates
}
}
}
}
}
});

The Beauty: Query A fetches user.name. Query B needs user.name and user.email. Query B can use cached name and only fetch email.

┌─────────────────────────────────────────────────────────────────┐
│ NORMALIZED CACHE STATE │
├─────────────────────────────────────────────────────────────────┤
│ │
│ User:123 │
│ ├── id: "123" ✓ (from Query A) │
│ ├── name: "Jane" ✓ (from Query A) │
│ └── email: "jane@..." ✓ (from Query B - incremental) │
│ │
│ Product:456 │
│ ├── id: "456" ✓ │
│ ├── name: "Widget" ✓ │
│ └── inventory: ref(Inventory:456) → linked entity │
│ │
│ Inventory:456 │
│ ├── stock: 42 ✓ │
│ └── warehouse: "NYC" ✓ │
│ │
└─────────────────────────────────────────────────────────────────┘

Level 5: Persisted Query Caching (Boss: Security)

Pre-register queries. Only allow known queries.

The Setup:

@Configuration
public class PersistedQueryConfig {

private final Map<String, Document> queryRegistry = new HashMap<>();

@PostConstruct
public void loadQueries() {
// Load from file or database
queryRegistry.put("abc123", parseDocument("{ user { name } }"));
queryRegistry.put("def456", parseDocument("{ products { name price } }"));
}

@Bean
public WebGraphQlInterceptor persistedQueryInterceptor() {
return (request, chain) -> {
String hash = getPersistedQueryHash(request);

if (hash != null) {
Document doc = queryRegistry.get(hash);
if (doc != null) {
return chain.next(request.transform(b -> b.document(doc)));
}
}

// In production, reject unknown queries
if (isProductionEnvironment()) {
return Mono.just(errorResponse("Unknown query"));
}

return chain.next(request);
};
}
}

Benefits:

  1. No query parsing at runtime (cached Document)
  2. Automatic cache key (the hash)
  3. Security: only approved queries run

Level 6: Cache Invalidation (Final Boss)

The two hardest problems in computer science: cache invalidation, naming things, and off-by-one errors.

Event-Driven Invalidation:

@Component
public class CacheInvalidationListener {

private final Cache<String, Object> cache;
private final ApplicationEventPublisher events;

@EventListener
public void onUserUpdated(UserUpdatedEvent event) {
// Invalidate user entity
cache.invalidate("User:" + event.getUserId());

// Notify clients (WebSocket push)
events.publishEvent(new CacheInvalidationNotification(
"User",
event.getUserId(),
event.getChangedFields()
));
}

@EventListener
public void onOrderCreated(OrderCreatedEvent event) {
// Invalidate user's orders list
cache.invalidate("User:" + event.getUserId() + ":orders");
}
}

Smart Invalidation Tags:

@SchemaMapping
@Cacheable(value = "products", key = "#id")
@CacheTag({"products", "product:${#id}", "inventory"})
public Product product(@Argument String id) {
return productRepository.findById(id);
}

// When inventory changes:
@CacheEvict(tags = "inventory") // Evicts all products that have inventory
public void updateInventory(String productId, int newStock) {
inventoryRepository.update(productId, newStock);
}

Level 7: Real-Time Cache (Secret Boss)

Cache that updates itself. No invalidation needed.

Subscription-Based Cache Sync:

@Configuration
public class RealtimeCacheConfig {

@Bean
public Consumer<EntityChangeEvent> cacheUpdater(NormalizedCache cache) {
return event -> {
switch (event.getType()) {
case CREATED, UPDATED -> cache.store(
event.getTypename(),
event.getId(),
event.getFields()
);
case DELETED -> cache.invalidate(
event.getTypename(),
event.getId()
);
}

// Push to connected clients via WebSocket
websocketSessions.stream()
.filter(s -> s.isWatchingEntity(event.getTypename(), event.getId()))
.forEach(s -> s.send(new CacheUpdateMessage(event)));
};
}
}

Client Subscription:

// Apollo Client with real-time updates
const client = new ApolloClient({
cache: new InMemoryCache(),
link: split(
({ query }) => isSubscription(query),
new WebSocketLink(wsClient),
new HttpLink({ uri: '/graphql' })
)
});

// Subscribe to cache updates
client.subscribe({
query: gql`
subscription OnCacheUpdate($types: [String!]!) {
cacheUpdate(types: $types) {
typename
id
fields
}
}
`,
variables: { types: ['User', 'Product'] }
}).subscribe(({ data }) => {
// Update local cache
client.cache.modify({
id: `${data.cacheUpdate.typename}:${data.cacheUpdate.id}`,
fields: data.cacheUpdate.fields
});
});

The Caching Decision Matrix

┌─────────────────────────────────────────────────────────────────────┐
│ WHICH CACHING LEVEL DO YOU NEED? │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ Question Recommended Level │
│ ───────────────────────────────────────────────────────────────── │
│ Is data mostly static? Level 1: HTTP/CDN │
│ Same queries repeated often? Level 2: Response Cache │
│ Different queries, same entities? Level 4: Normalized Cache │
│ Need security + speed? Level 5: Persisted Queries │
│ Data changes frequently? Level 6: Smart Invalidation │
│ Real-time requirements? Level 7: Subscription Sync │
│ │
│ Just starting out? Level 2 + Level 3 │
│ At scale (>1M req/day)? Level 4 + Level 5 │
│ Real-time app? Level 7 │
│ │
└─────────────────────────────────────────────────────────────────────┘

Cache Metrics You Need

@Component
public class CacheMetrics {

private final MeterRegistry registry;

public void recordHit(String cacheName) {
registry.counter("cache.hits", "cache", cacheName).increment();
}

public void recordMiss(String cacheName) {
registry.counter("cache.misses", "cache", cacheName).increment();
}

public void recordEviction(String cacheName, String reason) {
registry.counter("cache.evictions", "cache", cacheName, "reason", reason).increment();
}

// Calculate hit rate
public double getHitRate(String cacheName) {
double hits = registry.counter("cache.hits", "cache", cacheName).count();
double misses = registry.counter("cache.misses", "cache", cacheName).count();
return hits / (hits + misses);
}
}

Target Hit Rates:

  • HTTP Cache: 60-80%
  • Response Cache: 30-50%
  • Normalized Cache: 70-90%
  • Field Cache: 80-95%

Conclusion: Caching is a Journey

I started this post calling caching "the hardest easy problem." After 3,000 words, I stand by that assessment.

Start simple:

  1. Add HTTP caching for static queries
  2. Add response caching with TTL
  3. Measure your hit rates
  4. If they're low, move to normalized caching
  5. If data changes often, add invalidation events
  6. If you need real-time, embrace subscriptions

The perfect caching strategy doesn't exist. But a good enough one does, and it evolves with your application.

Happy caching. May your hit rates be high and your invalidations be precise.


No cache entries were permanently lost in the writing of this blog post. Some were evicted due to TTL.