Skip to content
advanced Phase 5 · Nested Selections and Object Relationships

Circular Queries and Cycle Guards

Preventing runaway recursive queries and resolver cycles in highly connected graphs.

40m
1 problems
Topic Progress 0%

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:

  1. Hand-expanded depth. The client spells out eight levels literally. The document is valid, and execution walks every level.
  2. Fan-out multiplication. Each level multiplies: friends with 200 per user reaches 40,000 at depth 3, 8,000,000 at depth 4, and 1.6 billion at depth 5.
  3. 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

0 / 1 solved
Spot the validation error and rewrite the query
Document validation vs schema cycles

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?

Question 1 options

2. friends returns 200 users per level. How many nodes does depth 4 reach from one starting user?

Question 2 options

3. Why run depth limiting as a validation rule instead of inside resolvers?

Question 3 options

4. What does a visited set NOT protect against?

Question 4 options

Flashcards

Question

Fragment cycle

Answer

A fragment reaching itself through a nested field — rejected by the No Fragment Cycles validation rule.

Question

Type cycle vs document loop

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

Answer

Visited set stops revisits through cycles; depth counter stops chains that never repeat. Use both.

Question

Depth limit vs cost budget

Answer

Depth limit counts levels cheaply; cost budget multiplies field fan-out and prices the query precisely.

Question

Where guards belong

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/first or a connection

Continue Learning