Where the Loop Actually Comes From
Where the Loop Actually Comes From
Cyclic schemas are ordinary: users follow users, comments reply to comments, categories contain categories. The danger is that a cyclic type plus an eager client equals unbounded work.
type User {
id: ID!
name: String!
friends: [User!]! # self-referencing edge
}
The tempting shortcut — a fragment that expands itself — is rejected before execution:
query Recurse {
user(id: "1") { ...Friend }
}
fragment Friend on User {
name
friends { ...Friend }
}
Fragment "Friend" cannot be spread here as it would create a cycle.
So a query never loops syntactically: documents are finite and fragment cycles fail validation. The three ways a schema cycle turns into a real cost:
- Hand-expanded depth. The client spells out eight levels literally. The document is valid, and execution walks every level.
- Fan-out multiplication. Each level multiplies:
friendswith 200 per user reaches 40,000 at depth 3, 8,000,000 at depth 4, and 1.6 billion at depth 5. - Resolver-side traversal. A resolver that eagerly walks both edges of a cycle in memory loops regardless of what the document says — the document asked for one field, the resolver did the walking.
The first two are client-shaped risks stopped by validation-time controls; the third is server code and needs its own guard. The next two chapters handle them in that order.
Fan-out arithmetic for friends at 200 per user, one document, no cycle:
| Depth | Nodes at that level | Total touched |
|---|---|---|
| 1 | 1 | 1 |
| 2 | 200 | 201 |
| 3 | 40,000 | 40,201 |
| 4 | 8,000,000 | 8,040,201 |
Four lines of query, eight million resolver calls — and writing it out explicitly is the only way to get there once fragment cycles are off the table.
Stop the Recursion Before Execution
Stop the Recursion Before Execution
Depth limiting rejects over-deep documents while the request is still being validated:
import depthLimit from 'graphql-depth-limit';
const apolloServer = new ApolloServer({
schema,
validationRules: [depthLimit(7)],
});
Validation error: query exceeds maximum depth 7 (found 9)
Placement matters: as a validation rule the rejection happens before a single resolver runs, so a hostile query costs one parse. Guarding inside resolvers is too late — the traversal already started.
Depth alone is blunt. It charges the same for posts { id } and posts { comments { authors { … } } } at each level even though one reads rows and the other multiplies them. Cost analysis weights fields instead:
level 1 posts cost 1
level 2 comments cost × 20 → 20
level 3 authors cost × 1 → 20
level 4 friends cost × 50 → 1000 per starting post
// budget style: each field declares a base cost and a list multiplier
const costs = { posts: 1, comments: 20, authors: 1, friends: 50 };
const total = priceQuery(document, costs); // reject if total > budget
Use both: a hard depth limit as the cheap backstop that catches pathological shapes, and a cost budget as the precise control that prices fan-out. See query depth limits and cost analysis and budgets for the tradeoffs of each.
One nuance interviewers like: because validation checks the document as written, a field hidden behind @skip(if: true) still counts toward depth — the validator does not evaluate variables. Budgeting cannot read the client's intent either, which is why the budget is conservative.
Resolvers That Walk the Graph Safely
Resolvers That Walk the Graph Safely
When the server itself traverses relations — building a comment thread, expanding a follower graph, materializing an org chart — document-level limits do not help, because the document looks innocent. Bound the walk in code:
function buildThread(comment, depth = 0, seen = new Set()) {
if (depth > 5 || seen.has(comment.id)) return null; // depth cap + visited set
const next = new Set(seen);
next.add(comment.id);
return {
...comment,
replies: comment.replyIds.map((id) =>
buildThread(loadComment(id), depth + 1, next)),
};
}
You need both guards, because they stop different shapes of runaway:
| Guard | Stops | Fails alone against |
|---|---|---|
| visited set | revisiting the same node via another path (cycles) | a chain that always produces new nodes |
| depth counter | any traversal of unbounded length | a diamond graph that re-does the same nodes many times |
Breadth-first with a queue and an explicit maxNodes budget is the same idea with a hard cap on total work — useful when depth is shallow but fan-out is extreme.
Schema review rules that catch this before it ships:
- Every self-referencing list field takes a limiting argument or returns a paginated connection.
- Eager in-memory expansion of relations is the N+1 pattern wearing a different hat — batch the lookups (see the N+1 pattern).
- Flag any recursive path where every edge is a non-null list: those are the fields clients will abuse first.
Interview framing, in this order: fragment cycles are a validation error, type cycles are legal, the runtime risk is fan-out plus resolver traversal, then name the three controls — depth limit, cost budget, visited set.
A cheap review heuristic to close on: list every self-referencing list field in the schema, then check each one for a limiting argument or a connection. Those lines are where a hostile query starts, and they are the only ones that need a second look.
Practice Problems
A teammate writes the document below to load a user and their followers-of-followers graph, and the server refuses it. Identify the error and the rule that produces it, then rewrite the document so it validates at depth 3, and name the server control that bounds it in production.
Example:
Input: type User { id: ID! name: String! friends: [User!]! } query Graph { user(id: "1") { ...Friend } } fragment Friend on User { name friends { ...Friend } }
Output: Error: Fragment "Friend" cannot be spread here as it would create a cycle. Rewritten (depth 3, explicit levels): query Graph { user(id: "1") { name friends { name friends { name friends { name } } } } }
The schema cycle is legal, but a fragment reaching itself would never finish describing itself, so the No Fragment Cycles rule rejects it before execution. Depth can only grow by spelling each level out, which is exactly what a depth limit then counts.
Optimal Solution — O(f^3) time, O(f^3) space
Replace the self-spread with explicit levels, then reject deeper documents in validation. With f friends per user the document costs 1 + f + f² + f³ nodes, so a depth limit of 3 caps the fan-out at f³.
// server
import depthLimit from 'graphql-depth-limit';
const server = new ApolloServer({
schema,
validationRules: [depthLimit(4)],
});
// document
query Graph {
user(id: "1") {
name
friends {
name
friends {
name
friends { name }
}
}
}
} Edge Cases:
- A friend list with zero entries terminates immediately at that level
- Depth is counted from the root field, so user itself is depth 1
- Cost analysis still needed: depth 3 with f = 1000 is a billion nodes
Quiz
1. Which document does the server reject before execution?
2. friends returns 200 users per level. How many nodes does depth 4 reach from one starting user?
3. Why run depth limiting as a validation rule instead of inside resolvers?
4. What does a visited set NOT protect against?
Flashcards
Question
Fragment cycle
Click to reveal answer
Answer
A fragment reaching itself through a nested field — rejected by the No Fragment Cycles validation rule.
Question
Type cycle vs document loop
Click to reveal answer
Answer
Recursive types are legal SDL; documents must terminate, so depth only grows by writing each level out.
Question
Two run-time guards for graph walks
Click to reveal answer
Answer
Visited set stops revisits through cycles; depth counter stops chains that never repeat. Use both.
Question
Depth limit vs cost budget
Click to reveal answer
Answer
Depth limit counts levels cheaply; cost budget multiplies field fan-out and prices the query precisely.
Question
Where guards belong
Click to reveal answer
Answer
Validation rules (before execution) for documents; resolver guards with seen/depth for server-side traversal.
Revision Notes
Key Takeaways
- 1. Cyclic schemas are legal; cyclic fragment spreads are a validation error, so runaway risk is fan-out and resolver traversal
- 2. Fan-out multiplies per level — 200 friends reaches 8 million nodes at depth 4
- 3. Depth limit and cost budget run as validation rules so rejected queries never touch a resolver
- 4. Server-side graph walks need a visited set and a depth counter; neither alone is sufficient
Interview Tips
- • Draw the distinction interviewers look for: legal schema cycles, illegal fragment cycles, bounded execution
- • Show the fan-out arithmetic for a self-referencing list before naming any tool — the estimate is the answer
- • Explain why you would configure both a depth limit and a cost budget, and where each runs in the request pipeline
Cheat Sheet
Cycle Guards Cheat Sheet
# ✗ rejected — No Fragment Cycles
fragment F on User { friends { ...F } }
# ✓ legal — finite document
user(id: "1") { friends { friends { friends { name } } } }
validationRules: [depthLimit(7)] // before execution
if (depth > 5 || seen.has(node.id)) return null; // inside traversal
- Recursive SDL is valid; recursive fragment spreads are not
- Fan-out math: 200^n nodes at depth n (200, 40k, 8M at depth 4)
- Depth limit = cheap shape check; cost budget = fan-out-weighted pricing
- Guard server walks with visited set AND depth/node cap
- Review rule: every recursive list needs
limit/firstor a connection