GraphQL caching, persisted queries, depth and complexity limits

What it is

Two problems that share a root cause, which is why they belong on one page.

GraphQL breaks HTTP caching. REST caches because a GET to a URL is a cacheable identity: the URL is the key, and CDNs, browsers and proxies all understand it. GraphQL sends a POST to a single endpoint with the query in the body, so every request looks identical to a cache and none of them are cacheable. You have discarded the entire HTTP caching stack in exchange for the ability to ask for exactly the fields you want.

GraphQL lets a client specify unbounded work. In REST the server decides what an endpoint costs. In GraphQL the client composes the query, so a client can ask for a shape whose cost is quadratic, exponential, or unbounded, and the server will try to answer it. This is not a hypothetical abuse case; a well-meaning client developer adding one nested field can multiply backend load.

Persisted queries address both at once. The client sends a hash instead of a query string; the server looks up the query it already knows. That makes the request small and GET-able (so it caches), and it means only queries you have approved can run (so cost is bounded by review rather than by runtime analysis).

What these are confused with: caching a GraphQL response is not the same as caching GraphQL data. Response caching stores the answer to a whole query. Entity caching stores individual objects (Product:P42) and reassembles responses from them. They have different hit rates, different invalidation problems, and most production systems need both.

The problem it solves

Concretely, on the cost side. A schema with a Product that has reviews, and a Review that has author, and an author that has reviews:

query {
  products(first: 100) {
    reviews(first: 100) {
      author {
        reviews(first: 100) {
          author {
            reviews(first: 100) { body }
          }
        }
      }
    }
  }
}

That is 100 x 100 x 100 x 100 = 100,000,000 leaf resolutions, from a query that fits on a screen. Even with perfect DataLoader batching the object count kills the process. Any schema with a cycle in it (and almost every schema has one) permits this, and introspection tells an attacker the schema.

Concretely, on the caching side. A product page in REST is GET /products/P42, cached at the CDN, served in 12 ms from an edge node in the user's city. The same page in GraphQL is a POST that reaches your origin every time. For a read-heavy public site, that difference is the majority of your traffic and the majority of your latency.

Mechanics

Persisted queries: the highest-value change

Two flavours, and the distinction matters.

Automatic persisted queries (APQ) are a bandwidth optimisation. The client sends a SHA-256 hash; if the server does not know it, the server replies PersistedQueryNotFound and the client retries with the full query, registering it. The server learns queries at runtime, so it is not a security control.

1. POST {extensions:{persistedQuery:{version:1, sha256Hash:"abc..."}}}
   -> {"errors":[{"message":"PersistedQueryNotFound"}]}
2. POST {query:"query Product($id:ID!){...}", extensions:{persistedQuery:{...hash}}}
   -> server stores hash -> query, answers
3. All subsequent clients: send hash only. 40 bytes instead of 1,200.

Registered (safelisted) persisted queries are the security control. Queries are extracted from the client at build time, published to the server as a manifest, and anything not in the manifest is rejected. The server never learns a query at runtime.

// persisted-query-manifest.json, generated from the client source at build time
{
  "format": "apollo-persisted-query-manifest",
  "operations": [
    { "id": "e0321f6b...", "name": "ProductDetail",
      "body": "query ProductDetail($id:ID!){product(id:$id){name price{amount}}}" }
  ]
}

This is the change that turns GraphQL from "clients can specify any work" into "clients can invoke approved operations," which is REST's cost model with GraphQL's field selection. My strong preference is to enable safelisting for any first-party client, because complexity limits are a runtime approximation of a question you can answer exactly at build time.

The catch: it only works for clients you build. A public API with third-party consumers cannot safelist, and that is exactly where you need the runtime limits below.

Once queries are persisted, they can be sent by GET:

GET /graphql?operationName=ProductDetail
            &variables={"id":"P42"}
            &extensions={"persistedQuery":{"version":1,"sha256Hash":"e0321f6b..."}}

A GET with a stable URL, which a CDN caches. That is how you get HTTP caching back, and it is the second reason persisted queries matter more than any other item here.

Complexity analysis, for the queries you cannot safelist

Depth limiting is the crude version and it is worth having anyway because it costs nothing:

GraphQL.newGraphQL(schema)
    .instrumentation(new ChainedInstrumentation(List.of(
        new MaxQueryDepthInstrumentation(10),
        new MaxQueryComplexityInstrumentation(1000, (env, complexity) -> {
            log.warn("rejected complexity {} for {}", complexity, env.getOperation());
            return complexity;
        })
    )))
    .build();

Depth alone is insufficient: a query of depth 2 requesting 1,000 items each with 1,000 sub-items is shallow and enormous. Complexity scoring is depth weighted by breadth, using the pagination arguments:

// Cost of a field = its own cost + (child costs x the requested page size)
public class PaginationAwareCalculator implements FieldComplexityCalculator {
    @Override
    public int calculate(FieldComplexityEnvironment env, int childComplexity) {
        Object first = env.getArguments().get("first");
        int multiplier = first instanceof Integer n ? n : 1;

        int own = switch (env.getField().getName()) {
            case "search"          -> 50;    // hits the search cluster
            case "recommendations" -> 100;   // model inference
            default                -> 1;
        };
        return own + (childComplexity * multiplier);
    }
}

The per-field weights are the part people skip and the part that makes this useful. A field backed by an in-memory lookup and a field backed by a model inference call have costs differing by three orders of magnitude, and a uniform cost model prices them the same.

Working the earlier attack through this: products(first:100) with children at first:100 three levels deep scores in the millions, so a limit of 1,000 rejects it before a single resolver runs. Rejection happens at validation time, before execution, which is what makes complexity analysis a defence rather than a mitigation.

Two more controls worth naming: MaxQueryAliasesInstrumentation, because aliases let one query request the same expensive field 500 times under different names and naive complexity calculators count it once; and disabling introspection in production, which is not security (the schema leaks through client bundles anyway) but does remove the most convenient way to discover the cyclic paths.

Caching, in four layers

LayerKeyHit rateInvalidation
CDN / HTTPGET URL (persisted query hash + variables)Highest, for public dataTTL, or purge by URL
Response cacheoperation hash + variables + auth scopeHigh for repeated queriesTTL, or tag-based purge
Entity cacheType:id (Product:P42)Highest reuse across different queriesPrecise: invalidate one entity
DataLoaderPer requestDeduplication within one request onlyDiscarded per request

Entity caching is where GraphQL's shape actually helps, and it is the layer teams reach for last. Because the response is assembled from typed objects with identities, a cached Product:P42 serves every query that touches that product, in any shape. A REST response cache cannot do this: /products/P42?include=reviews and /products/P42?include=inventory are separate cache entries containing overlapping data.

@DgsData(parentType = "Query", field = "product")
public CompletableFuture<Product> product(@InputArgument String id) {
    return cache.get("Product:" + id, () -> productService.load(id));
}

The invalidation story is correspondingly better. A price change invalidates Product:P42, and every cached query shape that included that product is now correct, because the shapes were never cached, only the entities.

@cacheControl propagates TTLs from the schema:

type Product @cacheControl(maxAge: 300) {
  id: ID!
  name: String! @cacheControl(maxAge: 3600)      # rarely changes
  price: Money! @cacheControl(maxAge: 30)        # changes often
  inventory: Int! @cacheControl(maxAge: 0)       # never cache
}

The server computes the response's cacheability as the minimum across every field in the selection set, and emits Cache-Control accordingly. A query asking only for name gets an hour; adding inventory makes the whole response uncacheable. That is correct and it has a design consequence: one volatile field poisons the cacheability of everything requested alongside it, so volatile fields belong in separate queries issued separately by the client.

@cacheControl(scope: PRIVATE) marks anything user-specific, which must never reach a shared cache. Getting that wrong means serving one user's data to another, which is the worst bug in this area and the reason auth scope must be part of every cache key above the entity layer.

A worked example: 94 percent origin traffic, and one field

A recipe site. Public content, heavy read traffic, GraphQL API serving web and mobile. About 40 million page views a month.

Before:

requests to origin:        100% (POST /graphql, nothing cacheable)
p50 latency:               210ms
p99 latency:               1,340ms
origin cost:               ~$14k/month (compute + database)
CDN:                       serving static assets only

They had a CDN and it was passing every API request straight through.

Change 1: persisted queries with a build-time manifest, sent by GET.

// Apollo Client, generating the manifest at build time
const client = new ApolloClient({
  link: createPersistedQueryLink({ sha256, useGETForHashedQueries: true })
    .concat(httpLink)
});
requests to origin:        100% -> 100%   (no change yet!)

No improvement, which surprised them. The queries were now GETs with stable URLs, and the CDN still passed them through because the responses carried no Cache-Control header. Persisted queries make caching possible; they do not make it happen.

Change 2: @cacheControl on the schema.

type Recipe @cacheControl(maxAge: 600) {
  id: ID!
  title: String!
  ingredients: [Ingredient!]!
  steps: [Step!]!
  viewCount: Int!            # <- no annotation, so it inherits the default of 0
}
requests to origin:        100% -> 94%

A 6 percent improvement on a page that should have been almost entirely cacheable. The cause was viewCount: an unannotated field defaults to maxAge: 0, the response TTL is the minimum across the selection set, so one uncached field made every recipe query uncacheable. The client requested it because the design showed a view count in the corner of the card.

Change 3: move the volatile field out.

type Query {
  recipe(id: ID!): Recipe                     # cacheable, 10 min
  recipeStats(id: ID!): RecipeStats @cacheControl(maxAge: 0)   # separate query
}

The client issues two queries: one cacheable for the content, one uncacheable for the counter, rendered when it arrives.

requests to origin:        94% -> 22%
p50 latency:               210ms -> 34ms      (CDN edge hit)
p99 latency:               1,340ms -> 290ms
origin cost:               ~$14k -> ~$4.2k/month

Change 4: entity cache for the residual 22 percent. The uncacheable stats query and personalised queries still hit origin, and those queries still touched recipe entities.

origin database queries:   -71% (entity cache hit rate 84% on Recipe:*)
p99 for uncacheable
  queries:                 290ms -> 110ms

Change 5: complexity limits, added after a third-party developer with an API key wrote a nested query that took 40 seconds and briefly saturated the database.

MaxQueryDepth:      10
MaxQueryComplexity: 1,500     (p99 of legitimate queries measured at 340)
MaxQueryAliases:    30

The limits were set from measured legitimate traffic: they logged complexity for two weeks without enforcing, found p99 at 340 and max at 890, and set the limit at 1,500. Enforcing from an estimate would have rejected real queries.

Final:

                        before      after
origin request share     100%        22%
p50 latency              210ms       34ms
p99 latency            1,340ms      290ms
origin cost            $14k/mo     $4.2k/mo
worst-case query cost  unbounded   1,500 complexity units

The headline number is that one unannotated integer field was costing about $9,000 a month. Nothing about it looked like a caching decision: a developer added viewCount to a card design, and the field's default TTL of zero propagated to the entire response. That is the failure mode of "minimum across the selection set" and it is invisible unless you are watching cache hit rates per operation.

Production evidence

Facebook has used persisted queries since before GraphQL was public, and the practice is described in early GraphQL talks: the client build extracts queries, ships IDs, and the server has a fixed set. Given that GraphQL's authors built the safelisting model into their own deployment from the start, treating it as an advanced optimisation rather than a default is backwards.

Apollo's APQ implementation documents the negotiation protocol above and is widely deployed; Apollo Server, Apollo Router and Apollo Client all support it, and the generate-persisted-query-manifest tool exists specifically for build-time safelisting.

GitHub's public GraphQL API uses a points-based rate limit rather than a request count: each query is scored (roughly, the number of nodes it could return) and clients get 5,000 points per hour, with the calculated cost returned in the response. That is the clearest public example of complexity analysis as a product-facing contract, and the design decision worth copying is returning the cost to the client so they can optimise rather than guess.

Shopify's Admin API uses the same model, with a leaky-bucket of query cost points and documented per-field costs. Both GitHub and Shopify publish their cost formulas, which is the honest way to ship this: an opaque limit is very difficult for a client developer to work around.

graphql-java ships MaxQueryDepthInstrumentation, MaxQueryComplexityInstrumentation and MaxQueryAliasesInstrumentation in the core library, and the GraphQL Foundation's security guidance lists depth limiting, complexity analysis, timeouts, disabling introspection and persisted queries as the standard set.

The debate

Persisted queries or complexity limits? Both, for different consumers. Safelisted persisted queries for first-party clients, because you know every query at build time and enforcing a manifest is an exact answer to a question complexity analysis approximates. Complexity limits for third-party consumers, because you cannot safelist queries you did not write. Teams that implement only complexity limits are running a runtime approximation for clients they fully control, which is more work and weaker.

Is complexity analysis worth the effort? It is genuinely hard to get right. The cost of a field depends on arguments, on data (a product with 3 reviews versus 30,000), and on cache state, and a static calculator sees none of that. The alternative, or rather the complement, is a hard execution timeout plus a resolver-count budget, which catches the expensive query without needing to predict it. My position: static complexity limits as a coarse gate, plus a hard timeout and a resolver-call budget as the real defence. Static analysis rejects the obviously absurd; the runtime budget catches what the calculator mispriced.

Where should caching live? The instinct is a CDN because it is the cheapest hit, and it only works for public, non-personalised data with persisted GET queries. Entity caching is the layer with the broadest applicability, because it serves personalised and public queries alike and its invalidation is precise. If I could implement only one, it would be entity caching; if I could implement two, CDN plus entity, and I would skip whole-response caching entirely because its hit rate is poor (every distinct query shape is a distinct key) and its invalidation is the hardest of the three.

Should you disable introspection in production? It is defence in depth, not security. The schema is discoverable from client bundles, from error messages, and by field-name guessing against suggestion responses. Disabling it removes the most convenient path and breaks legitimate tooling. My position: disable it on the public endpoint, keep it on an internal one, and do not count it as a control.

The uncomfortable trade nobody states. GraphQL's selling point is that clients ask for exactly what they need. Persisted safelisting removes that at runtime: clients ask for what was approved at build time. What remains is per-client field selection with build-time review, which is genuinely valuable and is not the "any query, any shape" promise. Being honest about that is better than discovering it during a security review, and for most first-party products the trade is clearly worth taking.

Follow-up Q&A

"Why doesn't GraphQL cache like REST?"

Because the cache key is gone. REST's key is the URL, which every CDN, proxy and browser understands. GraphQL POSTs to one endpoint with the query in the body, so every request is opaque and identical to a cache. Persisted queries restore a key: the hash plus variables in a GET URL is stable and cacheable, which is why persisted queries are a caching feature at least as much as a bandwidth one.

"A client can write an expensive query. How do you bound it?"

Layered. Persisted query safelisting for first-party clients removes the problem entirely, since only reviewed operations run. For third parties: depth limit as a cheap first cut, complexity scoring with per-field weights and pagination multipliers as the real gate, an alias limit because aliases multiply cost invisibly, and a hard execution timeout plus resolver-count budget as the backstop for whatever the calculator mispriced. Set the numeric limits from measured legitimate traffic, not from intuition: log complexity for two weeks before enforcing.

"How do you pick complexity weights?"

From what the field actually costs. A field resolved from an already-loaded parent is

  1. A field hitting a database with a DataLoader is a few. A field hitting a search cluster or running model inference is 50 to 100. Then multiply child costs by the first/limit argument, because that is what turns depth into breadth. Publish the formula, as GitHub and Shopify do, and return the computed cost in the response so clients can optimise rather than guess.

"How does @cacheControl compute a response's TTL?"

Minimum across every field in the selection set, plus the most restrictive scope. That means one field with maxAge: 0 makes the entire response uncacheable, which is correct and is a design constraint: volatile fields must be split into separate queries or you lose caching on everything requested with them. In the worked example one unannotated integer field defaulting to zero was costing about $9,000 a month.

"Entity cache or response cache?"

Entity, if you pick one. A cached Product:P42 serves every query touching that product regardless of shape, so the hit rate is far higher, and invalidation is precise: a price change invalidates one key. A response cache keys on the whole operation, so every distinct query shape is a distinct entry, hit rates are poor, and invalidating a product means finding every cached response that contained it. The one thing response caching wins on is that it avoids re-execution and re-serialisation entirely, which matters for genuinely hot identical queries.

"What breaks when you turn on safelisting?"

Anything that composes queries at runtime: a dynamic query builder, a debugging tool, a third-party integration, an internal dashboard someone wrote against your endpoint. The migration is to run in log-only mode first, recording which incoming queries are not in the manifest, for long enough to catch monthly jobs. Expect to find at least one internal consumer nobody remembered. And keep a separate non-safelisted endpoint with its own auth for the legitimate dynamic cases rather than weakening the main one.

Common misconceptions

"Persisted queries are a bandwidth optimisation." Automatic ones are. Safelisted ones are a security control and a caching enabler, and those are the larger benefits. Conflating APQ with safelisting means teams enable APQ and believe they have bounded query cost, which they have not: APQ registers whatever a client sends.

"Depth limiting stops expensive queries." A depth-2 query requesting 1,000 items each with 1,000 sub-items is shallow and enormous. Depth without breadth is half a control, which is why complexity scoring multiplies by pagination arguments.

"Persisted queries make responses cacheable." They make them cache-keyable. Without Cache-Control headers, a CDN still passes every request through, which is exactly what the worked example measured: persisted queries alone changed origin traffic by zero percent.

"Aliases don't matter." a: expensiveField b: expensiveField c: expensiveField is one field in a naive complexity calculation and three executions. Alias limits exist for this and are frequently omitted.

"Disabling introspection secures the API." The schema is in your client bundles, in error messages, and recoverable by field-name guessing against "did you mean" suggestions. It removes convenience, not capability.

Interview delivery note

Say this verbatim: "GraphQL gives up HTTP caching and gives clients control of query cost, and persisted queries address both: a hash in a GET URL is cacheable, and a build-time manifest means only approved operations run. For first-party clients that is strictly better than complexity analysis, which is a runtime approximation of a question you can answer exactly at build time." That connects the two halves of the topic through one mechanism, which is the insight.

The senior-versus-staff separator is @cacheControl taking the minimum across the selection set. A senior engineer describes the caching layers correctly. A staff engineer knows that one unannotated field defaults to maxAge: 0 and poisons the cacheability of everything requested with it, so volatile fields must be split into separate client queries, and can point at the cost: about $9,000 a month for one integer nobody thought of as a caching decision.

The second signal is setting limits from measured traffic. Saying "I would log complexity for two weeks without enforcing, find the p99 of legitimate queries, and set the limit at three to four times that" is what distinguishes shipping this from reading about it. Limits set from intuition reject real users.

Further reading

  • Apollo documentation on automatic persisted queries and generate-persisted-query-manifest, for the distinction between APQ and safelisting.
  • GitHub GraphQL API documentation, "Resource limitations," for a published points-based cost model returned to the client.
  • Shopify Admin API rate-limit documentation, for the leaky-bucket cost model and per-field costs.
  • graphql-java instrumentation reference (MaxQueryDepthInstrumentation, MaxQueryComplexityInstrumentation, MaxQueryAliasesInstrumentation) and the GraphQL Foundation's security best practices.