Direct answer
GraphQL's flexible field selection makes whole-response caching largely ineffective (every distinct query shape produces a distinct response), so effective GraphQL caching happens at a finer grain: normalized entity caching on the client, persisted queries to make server-side caching by query identity practical, and field- or object-level caching strategies that survive arbitrary client query shapes.
Structured elaboration
- Normalized client-side caches: a library like Apollo Client's cache normalizes responses by entity ID (a
Product:123 object is stored once, regardless of which query fetched it, or which fields were requested across different queries), so overlapping queries share cached entity data even when their exact field selections differ, and a mutation affecting Product:123 can update every query result referencing it without a full refetch.
- Server-side response caching: because two clients requesting even slightly different field sets produce different response bodies, naive whole-response caching (keyed on the raw query string) has poor hit rates; this works better when combined with persisted queries (see below), which constrain the space of distinct queries actually seen.
- Persisted queries: instead of sending the full query text on every request, the client sends a hash referencing a pre-registered query (registered at build time); this both reduces request size AND makes server-side response caching practical again, since the server now sees a small, known set of distinct queries rather than an unbounded space of ad-hoc client-constructed ones.
- Field vs. object-level caching: caching at the object level (an entity like a specific product, regardless of which query fetched it) is more reusable across different client queries than caching at the whole-response level; some GraphQL server implementations support field-level caching directives to fine-tune this further for expensive individual fields.
- Invalidation for mutations touching nested data: a mutation updating one entity can affect many different cached query results that reference it (directly, or through a relationship); a normalized cache's entity-level invalidation (update
Product:123 once, every referencing query result updates) is what makes this tractable, versus trying to enumerate and invalidate every affected whole-response cache entry.
Worked example
An Apollo Client cache normalized by entity ID means a query fetching Product(id: 123) { name, price } and a separate query fetching Product(id: 123) { name, description } both read and update the SAME normalized Product:123 entry for the name field they share, and a mutation updating Product:123's price automatically reflects in both queries' results on their next render, without either query needing to know about the other or trigger an explicit refetch.
Trade-offs and pitfalls
Whole-response server-side caching for GraphQL without persisted queries tends to have a poor hit ratio in practice, because the space of distinct query shapes a flexible client can construct is effectively unbounded; persisted queries are usually a prerequisite for server-side caching to be worthwhile at all, not an optional add-on. Normalized client caching requires entities to have stable, consistent IDs across every query that returns them; a schema that returns the same logical entity with inconsistent ID fields across different query paths breaks normalization silently.