Caching & performance — layer by layer
"GraphQL can't be cached" is a myth spread by teams who only tried one layer. There are five. Use them in order of cheapness.
Layer 0: don't do the work — DataLoader everywhere
The resolver-level N+1 is still the #1 performance defect we find in audits. Every resolver that fetches by ID goes through a per-request DataLoader (or your framework's batcher). Non-negotiable, and testable: assert query counts in integration tests, not just results.
// ❌ 1 + N queries for a 50-item list
author: (post) => db.user.findById(post.authorId)
// ✅ 2 queries total
author: (post, _, {loaders}) => loaders.user.load(post.authorId)
Layer 1: HTTP — APQ + GET + CDN
- Automatic Persisted Queries shrink requests to a hash; combined with
GET, your queries become CDN-cacheable URLs. - Public, per-user-invariant data (product pages, articles, pricing) belongs on the CDN with
Cache-Control— exactly like REST. GraphQL changes nothing here except making you set the headers deliberately (a response mixing per-user and public fields is uncacheable — split those queries).
Layer 2: response & entity caching at the gateway
- Response caching for whole-query results keyed on (operation, variables, auth scope). Brutal effectiveness on read-heavy traffic; needs a real invalidation story.
- Entity caching (federation routers) caches entities across queries — 50 different queries touching
Product:123share one cached copy. TTL + event-driven invalidation on product updates covers the vast majority of real workloads.
Layer 3: @defer for the slow tail
When one field is 10× slower than the rest of the query (recommendations, aggregations), don't make the page wait:
query ProductPage($id: ID!) {
product(id: $id) {
name
price
... @defer { recommendations { ...RecCard } }
}
}
The page renders from the fast fields; the slow fragment streams in. This is often a bigger UX win than any cache.
Layer 4: the client cache is part of your API
Normalized client caches (Apollo, Relay, urql-graphcache) live or die on your schema design:
- Stable, globally-unique
idfields on everything cacheable — the client dedupes across queries for free. - Mutations return the changed entities (payload types — see schema design) so the cache updates without refetching.
- Break these rules and every mutation in your app grows a hand-written
refetchQuerieslist. We've seen apps where that list was the biggest source of bugs.
Measure like a graph, not like REST
- Field-level timing (federated tracing / Apollo-style traces), not endpoint timing — "the API is slow" is useless; "
Product.reviewsp95 is 800ms" is a ticket. - Watch resolver count per request as a first-class metric; it's your N+1 alarm.
- In federation: fetch groups and plan depth are your latency floor — see N+1 at the gateway.