One Request Instead of a Chain of Endpoints
One Request Instead of a Chain of Endpoints
A product page needs a post, its comments, and each comment's author. Over REST that is at least three calls, and the client stitches the pieces by id:
GET /posts/42 → the post
GET /posts/42/comments → 12 comments
GET /users?ids=3,7,9,11,... → the authors (a batching trick the client invented itself)
GraphQL asks for the whole tree in one document:
query ProductPage {
post(id: "42") {
title
comments(limit: 5) {
body
author {
name
avatarUrl
}
}
}
}
The response mirrors the selection set key for key, so no stitching code exists:
{
"data": {
"post": {
"title": "Designing Data-Intensive Applications",
"comments": [
{ "body": "The replication chapter…", "author": { "name": "Ada", "avatarUrl": "/a.png" } }
]
}
}
}
Four properties of this shape are worth locking in:
- Depth is chosen by the client. Nothing stops a caller from continuing the tree with
author { posts { comments { … } } }. The server cannot assume a query stops where your screen stops. - Only selected fields execute. A selection of
titlealone never touches comment resolvers, so cost is driven by depth and fan-out together, not by the schema's size. - The schema guarantees the shape, not the resolvers.
commentsis typed[Comment!]!, so the client can rely on structure without null-checking every level. - One round trip, no matter how deep. Nesting converts a client-side join problem into a single server-side traversal.
See query variables for how the value of id: "42" reaches the server without being baked into the document text.
What Runs at Each Level
What Runs at Each Level
A nested selection is not one resolver walking a tree. Every field has its own resolver, and each receives the object its parent field just produced.
type Query {
post(id: ID!): Post
}
type Post {
id: ID!
title: String!
comments(limit: Int): [Comment!]!
}
type Comment {
id: ID!
body: String!
author: User!
}
const resolvers = {
Query: {
post: (_root, { id }) => db.post.findUnique({ where: { id } }),
},
Post: {
// parent is the post Query.post just returned — its id is already in memory
comments: (parent, { limit }) =>
db.comment.findMany({ where: { postId: parent.id }, take: limit ?? 20 }),
},
Comment: {
author: (parent) => db.user.findUnique({ where: { id: parent.authorId } }),
},
};
Dispatch order for the query above:
| Level | Field | First argument |
|---|---|---|
| 1 | Query.post |
the root value, usually undefined |
| 2 | Post.comments |
the resolved post |
| 3 | Comment.author |
the resolved comment |
The parent is handed down by value from the previous resolver, never re-read by id. That has one important consequence: if the object arriving at level 2 carries no authorId, the Comment.author resolver reads undefined. Validation already passed — it only ever compares the document against the schema — so the failure surfaces at execution, as null on a nullable field or an error that propagates upward on a non-null one. Null propagation covers exactly how far that error travels.
The part reviewers miss is multiplication: one Query.post call, one Post.comments query, then one author query per comment. Twelve comments mean thirteen database reads behind a single HTTP request. The query is three lines long and still expensive.
Depth Limits and the Cost Hiding in Plain Sight
Depth Limits and the Cost Hiding in Plain Sight
Count what a motivated client can reach:
post depth 1
comments depth 2
author depth 3
posts depth 4
comments depth 5
author depth 6
None of that is invalid — it is a finite document over a legal schema. But each level multiplies the work of the one above it: a catalog page with 50 posts, 20 comments each, and 3 followed users per author turns into thousands of resolver calls behind one request. The query reads as harmless because each level looks harmless alone.
Two controls, applied together:
import depthLimit from 'graphql-depth-limit';
const server = new ApolloServer({
schema,
validationRules: [depthLimit(6)], // reject deeper documents before execution
});
Depth counting is blunt: posts { id } and posts { comments { author { … } } } cost the same at each level even though their fan-out differs wildly. Pair it with cost analysis, which weights each field by how much it multiplies — see cost analysis and budgets and query depth limits.
Review checklist for nested selections:
- Ask what the fan-out product is at the deepest level, not whether each level reads clearly.
- Every list field on a deep path should accept
limit/firstor sit inside a paginated connection — see Relay-style pagination. - Treat a three-level selection containing two list fields as a performance change, not a formatting change.
- Check that the depth limit is actually wired into the server's validation rules; installing the package alone does nothing.
Interview framing: state that the client controls depth, name the two server-side controls (depth limit plus cost budget), and walk one concrete fan-out multiplication — 50 × 20 × 3 — to show you can price a query by reading it.
Practice Problems
Quiz
1. For the query post { comments { author { name } } } on a post with 12 comments, how many times does the Comment.author resolver execute?
2. What is the first argument passed to the Post.comments resolver?
3. Why can over-deep nesting slip past code review?
4. Which control rejects a 12-level document before a single resolver runs?
Flashcards
Question
Why one nested query beats three REST calls
Click to reveal answer
Answer
The server traverses relationships for you and returns the requested tree in one round trip; the client never joins by id.
Question
First argument of a field resolver
Click to reveal answer
Answer
The parent object produced by the field above it; the root value (usually undefined) for root fields.
Question
Who decides query depth
Click to reveal answer
Answer
The client writes it, so the server must enforce a maximum depth as a validation rule before execution.
Question
Fan-out product
Click to reveal answer
Answer
Multiply the list sizes along the deepest path (50 × 20 × 3) to estimate resolver calls for one request.
Question
Validation vs runtime for missing parent data
Click to reveal answer
Answer
Validation only compares document to schema; a parent missing a property fails at execution, yielding null or an error.
Revision Notes
Key Takeaways
- 1. A nested selection returns the requested tree in one round trip, and the response shape mirrors the document
- 2. Every field runs its own resolver with the parent object as argument one, so list levels multiply database work
- 3. Validation never inspects data — missing parent properties fail at execution, not before
- 4. Depth limit and cost budget together are the server-side answer to client-chosen depth
Interview Tips
- • Walk a query level by level: which resolver runs, what it receives as parent, and how many times it runs for a given data size
- • Estimate fan-out out loud when shown a deep query — interviewers are checking whether you multiply list sizes or just count lines
- • Name both controls (depth limit, cost budget) and say where they run: validation, before any resolver executes
Cheat Sheet
Nested Selection Cheat Sheet
query ProductPage {
post(id: "42") { # depth 1 — Query.post
title
comments(limit: 5) { # depth 2 — Post.comments(parent)
body
author { name avatarUrl } # depth 3 — Comment.author(parent)
}
}
}
- Response shape mirrors the selection set; only selected fields execute
- Dispatch order: root resolver (parent = root value) → each level passes its object down
- Resolver cost = product of list sizes on the deepest path (50 × 20 × 3 = 3000 calls)
- Missing parent property → runtime null/error, never a validation error
- Server controls: depth limit + cost budget, both as validation rules before execution
- Every list field on a deep path needs
limit/firstor a paginated connection