Anatomy of a Query Plan
Anatomy of a Query Plan
A client operation is a document; a query plan is what the gateway compiles it into before any subgraph sees traffic. The plan is a small tree with three node kinds — Fetch (one GraphQL request to one subgraph), Sequence (children run one after another), and Parallel (children run concurrently) — plus a requires list on fetches that consume entity representations.
Take an operation where two branches are independent:
query ProductPage($id: ID!) {
product(id: $id) { id name reviews { rating author { displayName } } }
me { id }
}
QueryPlan
├── [0] Parallel
│ ├── [0.0] Fetch products query ($id: ID!) { product(id: $id) { id name } }
│ └── [0.1] Fetch accounts query { me { id } }
└── [1] Sequence
├── [1.0] Fetch reviews requires: [{ __typename: "Product", id }]
│ _entities(representations) { ... on Product { reviews { id rating author { id } } } }
└── [1.1] Fetch accounts requires: [{ __typename: "User", id }]
_entities(representations) { ... on User { displayName } }
Every line has a precise meaning:
- The generated operation inside a fetch is what actually travels — a normalized document with only the fields that subgraph owns, never the client's original query.
- requires lists the representations that must exist before the fetch can run, which is why
[1.0]waits for[0.0]and[1.1]waits for[1.0]. - path (implicit in the tree position) tells the merge step where each partial result is spliced back into the response.
- Branches with no data dependency land in
Parallel:medoes not depend on the product chain, so it runs alongside it rather than behind it.
Count the fetches: four. Count the leaf fields: eight. Fetch count follows subgraph boundaries crossed, not field count — which is exactly why adding a subgraph to a hot operation costs latency and merging two subgraphs into one usually saves it.
How the Gateway Decides What Runs When
How the Gateway Decides What Runs When
Planning is dependency analysis over field ownership. A fetch becomes runnable the moment every input in its requires set has been produced by an earlier fetch, and the planner schedules whatever is runnable concurrently.
Five behaviors fall out of that rule:
- Root fields in the same subgraph collapse into one fetch. If
topProductsandbestSellersboth live in catalog, the gateway sends one document, not two. Splitting them would double the round trip for no benefit. - Entity hops chain. Reviews cannot be requested until the catalog fetch has returned the
Productkeys that identify what to ask for. This is where sequential depth comes from, and it is why cross-subgraph nesting in the client's query turns into latency. @requiresadds an inbound edge. When a subgraph computes a field from data it does not own, the planner schedules the producing fetch first and threads the data into the representations. A single@requirescan turn an otherwise parallel plan into a sequence of three.- Conditionals are resolved, not forwarded.
@skip/@includebecome conditional branches in the plan, and fragments are inlined into each subgraph's operation, so a subgraph never receives a fragment it cannot resolve. - Variables are passed through selectively. Each fetch receives only the variables its generated operation references; the rest stay at the gateway.
The cache-friendly consequence: a plan depends on the shape of an operation — the normalized document and operation name — not on variable values. The same document with id: "p1" and id: "p999" shares one plan. That property is what makes plan caching safe, and what makes a plan worth inspecting when an operation misbehaves: the plan is stable, so the problem is in the data or in a subgraph, not in the request you happened to send.
Operating the Gateway
Operating the Gateway
Plan cache. The first execution of a new operation shape pays a planning cost, so cold gateways show a latency spike on every new client release. Track plan-cache hit rate; a sudden drop means clients shipped new documents (or an attacker is spraying documents to force re-planning — a reason to pair the gateway with persisted queries). Cache entries are tiny and bounded by the number of distinct shapes, which is finite and measurable.
Entity cache. The gateway already holds entity representations during a plan; keeping them briefly turns repeated reads into memory hits. The key must include subgraph, __typename, the key fields, and the requested field set — two queries asking for different fields of the same product are different entries. Keep TTLs short and invalidate on mutations to that entity, or you will ship stale prices.
Partial failure is normal, not exceptional. When one subgraph errors or times out, the gateway returns HTTP 200 with entries in errors[] and null wherever nullability allows:
{
"data": { "product": { "name": "Bottle", "reviews": null } },
"errors": [{ "path": ["product", "reviews"], "extensions": { "code": "SUBGRAPH_ERROR" } }]
}
That is a feature — the page still renders — but it means alerting on HTTP status alone will show a green service while half the fields are null. Alert on error entries per operation and per subgraph instead, and set per-fetch timeouts so one slow database cannot hold the whole operation open.
Plan exposure. Apollo Explorer and equivalent tooling render the plan for any operation. Use it as the first step of triage: "which subgraph served this field, and how many hops did it take?" answers in seconds what a code archaeology session would take an afternoon. If you can print the plan for a production operation, you can prove ownership rather than argue about it.
Practice Problems
Given field ownership and an operation, write the query plan: name every fetch, state what each waits on, mark which branches can run in parallel, and count the network round trips. Ownership: catalog owns product/Query.product, reviews owns Product.reviews, accounts owns User.displayName and Query.me.
Example:
Input: query Page($id: ID!) { product(id: $id) { name reviews { rating author { displayName } } } me { id } }
Output: Parallel Fetch 1 (catalog): { product(id: $id) { id name } } # no deps Fetch 2 (accounts): { me { id } } # no deps Sequence after Fetch 1: Fetch 3 (reviews): _entities([{Product,id}]) { reviews { id rating author { id } } } Fetch 4 (accounts): _entities([{User,id}]) { displayName } # waits on Fetch 3 round trips: 4 (2 parallel, then 2 sequential)
me is independent of the product chain so it runs concurrently with Fetch 1. Reviews needs Product keys from Fetch 1, and accounts needs User references that only reviews returns, so those two chain. The plan never forwards the client document — each fetch carries only the fields its subgraph owns.
Optimal Solution — O(F) to build, where F is fetch nodes; critical path = longest dependency chain time, O(F) space
Build a dependency graph: an edge exists from fetch A to fetch B when B’s required representations are produced by A. Schedule the resulting DAG with independent nodes in parallel.
{
"kind": "QueryPlan",
"node": {
"kind": "Parallel",
"nodes": [
{ "kind": "Fetch", "service": "catalog",
"operation": "query($id:ID!){ product(id:$id){ id name } }" },
{ "kind": "Fetch", "service": "accounts",
"operation": "query{ me{ id } }" },
{ "kind": "Sequence", "nodes": [
{ "kind": "Fetch", "service": "reviews",
"requires": ["__typename", "id"],
"operation": "query($r:[_Any!]!){ _entities(representations:$r){ ... on Product { reviews { id rating author { id } } } } }" },
{ "kind": "Fetch", "service": "accounts",
"requires": ["__typename", "id"],
"operation": "query($r:[_Any!]!){ _entities(representations:$r){ ... on User { displayName } } }" }
] }
]
}
} Edge Cases:
- Two root fields in the same subgraph must merge into one fetch rather than run in parallel
- If reviews did not return author ids, the accounts hop would be impossible and the plan would fail to build
- A @requires on a reviews field would insert an extra producing fetch ahead of Fetch 3
Quiz
1. What does a fetch node with requires: [{ __typename: "Product", id }] mean?
2. Two root fields live in the same subgraph. How does a good planner schedule them?
3. What is a query plan cache keyed on?
4. One subgraph returns an error mid-operation. What does the client receive?
Flashcards
Question
Query plan
Click to reveal answer
Answer
A tree of Fetch/Sequence/Parallel nodes the gateway compiles from an operation before contacting subgraphs.
Question
requires (plan node)
Click to reveal answer
Answer
Entity representations that must be produced by an earlier fetch before this one can run.
Question
Plan cache key
Click to reveal answer
Answer
Normalized document + operation name. Variable values never affect a plan, so they never enter the key.
Question
Fetch count vs field count
Click to reveal answer
Answer
Fetches follow subgraph boundaries crossed, not selected fields — merging owners is what reduces latency.
Question
Partial failure shape
Click to reveal answer
Answer
HTTP 200 + errors[] + nulls where nullable; alert on error entries, never on status code alone.
Revision Notes
Key Takeaways
- 1. A plan is Fetch/Sequence/Parallel nodes driven by data dependencies from keys and @requires
- 2. Independent branches run concurrently; entity hops chain, and that chaining is your latency budget
- 3. Plan cache hit rate, per-subgraph error entries, and rendered plans are the gateway’s core operational signals
Interview Tips
- • Draw a small plan on the whiteboard: name each fetch, say what it waits on, and count round trips — interviewers are checking that you think in dependencies
- • Be ready to explain why plan caches are keyed on document shape and how entity caching differs from plan caching
Cheat Sheet
Plan node kinds
Fetch one GraphQL request to one subgraph (generated operation, not the client's)
Sequence child runs only after parent produces required representations
Parallel children with no data dependency run concurrently
Scheduling rules
- Root fields owned by the same subgraph merge into one fetch
- Entity hop waits for key fields; @requires adds an inbound dependency
- @skip/@include become conditional branches; fragments are inlined
- Round trips ≈ subgraph boundaries crossed, not fields selected
Operate it
- Watch: plan-cache hit rate, per-subgraph error entries, per-fetch p95
- Entity cache key = subgraph + __typename + key fields + requested field set
- Triage with plan exposure tooling: print the plan, name the owning subgraph