How GraphQL executes a query
How GraphQL parses queries into an AST, validates against a schema, resolves fields through the resolver tree, and handles N+1 problems with DataLoader batching.
The Interview Question
Interviewer: "Your team runs a GraphQL API that serves a mobile app. A product manager complains that the 'user profile' screen is slow. You look at the logs and see that loading one user triggers 47 SQL queries. Walk me through how GraphQL executes a query, and explain how this N+1 problem happens at the resolver level."
This question separates candidates who think GraphQL is "just a query language" from those who understand the execution engine: parsing, validation, resolver tree traversal, and the batching patterns that make it performant. The interviewer is testing whether you can trace a query from the raw string all the way to the SQL queries it generates.
What to Clarify Before Answering
You: "Before I walk through the execution model, let me clarify..."
- "Are we using a schema-first approach (SDL) or code-first (programmatic schema)? The execution model is the same, but the resolver wiring differs."
- "Is DataLoader (or an equivalent batching solution) already in place, or is this a naive resolver implementation?"
- "Is the schema stitched from multiple services, or is it a monolithic GraphQL server?"
- "What is the query depth? A shallow query with N+1 behaves differently from a deeply nested query."
- "Are we using subscriptions or just queries and mutations?"
Why this matters: The N+1 problem is not inherent to GraphQL. It is a consequence of how resolvers are wired. A candidate who asks about DataLoader immediately signals they have operated a GraphQL API in production, not just read tutorials.
The 30-Second Answer
GraphQL execution has four phases. First, the lexer and parser transform the raw query string into an Abstract Syntax Tree (AST). Second, the validator checks the AST against the schema to ensure every field, argument, and type is valid. Third, the executor walks the AST depth-first, calling a resolver function for each field. Each resolver returns data (or a promise) for that field, and the executor recursively resolves child fields. Fourth, the results are assembled into the response JSON matching the query shape. The N+1 problem occurs because each resolver runs independently: if you query 10 users and each user has a posts resolver that hits the database, you get 1 query for users + 10 queries for posts. DataLoader solves this by collecting all resolver calls within a single tick of the event loop and batching them into one query.
The Architecture Overview
The execution pipeline is a clean chain: raw string in, structured JSON out. I find it helpful to think of the executor as a tree walker that mirrors the shape of your query. Every field in the query maps to a resolver function, and the executor calls them in a predictable order: parent fields first, then child fields.
The critical design decision in this pipeline is that resolvers are independent. Each resolver knows how to fetch its own data but knows nothing about what other resolvers are doing. This isolation makes schema composition clean but creates the N+1 problem. DataLoader sits between resolvers and data sources, collecting individual fetches and batching them.
Parsing: From String to AST
The first phase transforms the raw query string into a structured tree that the execution engine can traverse. This happens in two steps: lexing and parsing.
Lexing (tokenization)
The lexer scans the query character by character and produces a stream of tokens. Each token has a type and a value.
// Input query
{ user(id: 1) { name posts { title } } }
// Token stream
{ β LEFT_BRACE
user β NAME("user")
( β LEFT_PAREN
id β NAME("id")
: β COLON
1 β INT("1")
) β RIGHT_PAREN
{ β LEFT_BRACE
name β NAME("name")
posts β NAME("posts")
{ β LEFT_BRACE
title β NAME("title")
} β RIGHT_BRACE
} β RIGHT_BRACE
} β RIGHT_BRACE
The lexer is simple and fast. It rejects malformed syntax early (unclosed strings, invalid characters) before the more expensive parsing step.
Parsing (AST construction)
The parser consumes the token stream and builds an Abstract Syntax Tree. Each node in the AST represents a structural element of the query: operation type, field selections, arguments, fragments, and directives.
The AST is a faithful representation of the query structure, not the schema. It preserves fragments, aliases, variables, and directives exactly as written. The validator will check this tree against the schema in the next phase.
Why parsing matters for performance
Parsing is deterministic and fast (microseconds for typical queries). But for very large queries (10,000+ fields), parsing cost becomes measurable. This is why persisted queries exist: you parse the query once at deploy time and store the AST. At runtime, the client sends a hash instead of the full query string, skipping parsing entirely.
Validation: Checking Against the Schema
After parsing, the validator walks the AST and checks every node against the schema. This phase catches errors before any resolver runs, which means no partial execution or wasted database calls.
The validator checks:
| Check | Example Error | Phase |
|---|---|---|
| Field existence | Cannot query field "emial" on type "User" (typo) | Field validation |
| Argument types | Int cannot represent non-integer value "abc" | Argument validation |
| Required arguments | Field "user" argument "id" is required | Argument validation |
| Fragment type conditions | Fragment on "Post" cannot be spread in "User" context | Fragment validation |
| Directive placement | Directive @skip not allowed on FIELD_DEFINITION | Directive validation |
| Variable type matching | Variable "$id" of type "String" used in position expecting "Int" | Variable validation |
Validation runs all checks in a single pass over the AST. If any check fails, the server returns an errors array with precise locations (line and column) pointing to the problematic part of the query. No data is returned.
{
"errors": [{
"message": "Cannot query field \"emial\" on type \"User\". Did you mean \"email\"?",
"locations": [{ "line": 3, "column": 5 }]
}],
"data": null
}
The key insight
Validation is one of GraphQL's strongest advantages over REST. With REST, a typo in a field name silently returns null or is ignored. With GraphQL, it is caught before execution and returns a helpful error. This is why GraphQL tooling (autocomplete, linting) works so well: the schema is a complete type system.
Execution: The Resolver Tree
This is the core of GraphQL and where I spend most of my time when debugging performance issues. The executor takes the validated AST and walks it, calling a resolver function for each field.
How resolvers work
A resolver is a function that takes four arguments and returns the data for one field:
// Resolver signature
resolve(parent, args, context, info)
// parent - The return value of the parent field's resolver
// args - The arguments passed to this field in the query
// context - Shared per-request state (auth, DataLoader instances, DB connection)
// info - AST metadata (field name, return type, path in query)
The executor calls resolvers top-down, passing each resolver's return value as the parent argument to its children.
// For the query: { user(id: 1) { name posts { title } } }
1. Execute root Query.user resolver with args {id: 1}
β Returns: { id: 1, name: "Alice" }
2. Execute User.name resolver with parent = { id: 1, name: "Alice" }
β Returns: "Alice" (default resolver reads parent.name)
3. Execute User.posts resolver with parent = { id: 1, name: "Alice" }
β Returns: [{ id: 10, title: "Hello" }, { id: 11, title: "World" }]
4. Execute Post.title resolver for each post
β Returns: "Hello", "World" (default resolvers)
Default resolvers
Most fields do not need custom resolvers. If no resolver is defined for a field, GraphQL uses a default resolver that simply reads parent[fieldName]. This is why you only write resolvers for fields that need custom logic: database queries, computed fields, or data from external sources.
Execution order
The executor processes fields in a specific order:
For queries, sibling fields at the same depth execute in parallel (when resolvers return promises). For mutations, root fields execute serially in the order they appear in the query. This is a deliberate design decision: mutations have side effects, so order matters.
Mutations execute serially
If you send a mutation that creates a user and then creates a post for that user, GraphQL guarantees the user is created first. This serial execution is part of the spec, not an implementation detail. Do not depend on parallel mutation execution.
The N+1 Problem and DataLoader
This is the most important operational topic for anyone running GraphQL in production. I have seen it bring down databases, and the fix is straightforward once you understand the mechanism.
How N+1 happens
Consider this query:
{
users(first: 10) {
name
company {
name
}
}
}
Without batching, the execution looks like this:
1. Query.users resolver β SELECT * FROM users LIMIT 10 (1 query)
2. User.company resolver for user 1 β SELECT * FROM companies WHERE id = 5
3. User.company resolver for user 2 β SELECT * FROM companies WHERE id = 5 (duplicate!)
4. User.company resolver for user 3 β SELECT * FROM companies WHERE id = 8
... (10 queries, some duplicated)
That is 1 + 10 = 11 queries. For a query with three levels of nesting, it compounds: 1 + 10 + 100 = 111 queries. This is the N+1 problem.
How DataLoader solves it
DataLoader is a utility that sits between resolvers and data sources. It collects all the keys requested during a single tick of the event loop, deduplicates them, and makes one batched request.
// DataLoader batch function
const companyLoader = new DataLoader(async (companyIds) => {
// companyIds = [5, 5, 8, 3, 5, 8, 12, 3, 8, 5]
// Deduplicated: [5, 8, 3, 12]
const companies = await db.query(
'SELECT * FROM companies WHERE id IN ($1)',
[uniqueIds]
)
// Return in the same order as input keys
return companyIds.map(id => companies.find(c => c.id === id))
})
// Resolver uses the loader
User: {
company: (user, args, context) => context.companyLoader.load(user.companyId)
}
Instead of 10 individual queries, DataLoader batches them into one:
SELECT * FROM companies WHERE id IN (5, 8, 3, 12) // 1 query instead of 10
DataLoader is per-request, not global
A common mistake is creating DataLoader instances at server startup and sharing them across requests. DataLoader caches results internally. A shared loader means User A might see User B's data from a previous request. Always create loaders in the context factory, which runs once per request.
Security: Protecting the Execution Engine
GraphQL's flexibility is also its attack surface. A malicious client can craft queries that exhaust server resources. I will walk through the three primary defenses.
Query depth limiting
Deeply nested queries can explode resolver calls exponentially:
# Malicious query - each level multiplies resolvers
{
user {
friends {
friends {
friends {
friends {
name # 4 levels deep - could be millions of resolvers
}
}
}
}
}
}
Set a maximum depth (typically 7-10 for most schemas):
// Depth limit validation rule
const depthLimit = require('graphql-depth-limit')
const server = new ApolloServer({
validationRules: [depthLimit(10)]
})
Query complexity analysis
Depth limiting alone is insufficient. A shallow but wide query can also be expensive:
# Shallow but expensive - 10,000 items with joins
{
users(first: 10000) {
posts(first: 100) {
comments(first: 100) {
text
}
}
}
}
Assign complexity costs to fields and reject queries that exceed a threshold:
// Complexity calculation
// users(first: 10000) = 10000
// posts(first: 100) per user = 10000 * 100 = 1,000,000
// comments(first: 100) per post = 1,000,000 * 100 = 100,000,000
// Total: 100,010,000 β REJECTED (exceeds limit of 10,000)
Persisted queries
Instead of accepting arbitrary query strings from clients, preregister allowed queries at deploy time. The client sends a hash (like sha256:abc123) and the server looks up the corresponding query.
This eliminates the entire category of query-based attacks. I recommend persisted queries for any production GraphQL API that does not need to support ad-hoc queries from third-party developers.
Subscriptions: Real-Time Execution
Subscriptions extend the execution model for real-time data. Instead of a single request-response cycle, a subscription opens a persistent connection and pushes data to the client whenever a specified event occurs.
How subscriptions work
- Client sends a subscription query over WebSocket
- Server validates and stores the subscription
- When a relevant event fires (mutation, external event), the server executes the subscription's selection set against the event payload
- The result is pushed to the client over the WebSocket
// Subscription query
subscription {
newMessage(channelId: "general") {
text
author { name }
}
}
// Server-side: when a message is created
pubsub.publish('NEW_MESSAGE', {
newMessage: { text: "Hello", authorId: 5, channelId: "general" }
})
// The executor runs the subscription selection set against the event payload
// Result pushed to client: { data: { newMessage: { text: "Hello", author: { name: "Alice" } } } }
The execution of the selection set (resolving author.name from authorId) uses the same resolver tree as queries. DataLoader batching applies here too.
Fragments and Type Resolution
Fragments are a query-level feature that the executor must resolve. There are two kinds: named fragments and inline fragments.
Union and interface resolution
When a field returns a union or interface type, the executor must determine the concrete type of each object to know which fields to resolve.
# Schema
union SearchResult = User | Post | Comment
# Query using inline fragments
{
search(query: "hello") {
... on User { name email }
... on Post { title body }
... on Comment { text }
}
}
The executor calls resolveType(obj, context, info) for each result to determine its concrete type. Then it only resolves the fields in the matching fragment.
// resolveType implementation
SearchResult: {
__resolveType(obj) {
if (obj.email) return 'User'
if (obj.title) return 'Post'
if (obj.text) return 'Comment'
}
}
Why resolveType matters for performance
If resolveType needs a database call to determine the type (for example, checking a type column), that call happens for every item in the result. For a search returning 100 items, that is 100 type resolution calls. Use DataLoader for type resolution too, or ensure the type is available on the parent object.
Pagination: Cursor-Based vs Offset
GraphQL does not prescribe a pagination model, but the Relay specification defines a cursor-based pattern that has become the standard.
Offset pagination (simple but fragile)
{
users(limit: 10, offset: 20) {
name
}
}
Offset pagination maps directly to SQL LIMIT/OFFSET. It breaks when data is inserted or deleted between pages (items shift, causing duplicates or skips).
Cursor-based pagination (Relay spec)
{
users(first: 10, after: "cursor_abc") {
edges {
cursor
node { name }
}
pageInfo {
hasNextPage
endCursor
}
}
}
The cursor is an opaque string (typically a base64-encoded row identifier or timestamp). The server uses it to query WHERE id > cursor_value LIMIT 10, which is stable regardless of inserts or deletes.
| Aspect | Offset | Cursor |
|---|---|---|
| Stability | Fragile (items shift on insert/delete) | Stable |
| Performance | O(offset) in database | O(1) with indexed cursor column |
| Random access | Yes (jump to page 50) | No (must traverse sequentially) |
| Implementation | Simple | More complex |
I use cursor-based pagination for any list that changes frequently (feeds, messages, notifications) and offset pagination only for relatively static datasets where random page access matters (admin dashboards, search results).
What Happens When Things Break
| Failure | What Happens | How to Detect | How to Fix |
|---|---|---|---|
| Resolver throws uncaught error | Partial response: field returns null, error in errors array | errors array in response | Add error handling in resolver, use error formatting middleware |
| DataLoader batch function fails | All pending .load() calls reject | Cascading nulls in nested fields | Add try/catch in batch function, return Error for individual failures |
| N+1 with no DataLoader | Database overwhelmed, slow responses | Query count metrics (100+ queries per request) | Add DataLoader for every relationship resolver |
| Circular fragment | Infinite AST during parsing | Validation error: "Cannot spread fragment within itself" | Fix query, add fragment depth limit |
| Query too complex | Server OOM or timeout | Slow response, high memory usage | Add complexity analysis, depth limiting |
| Schema drift (client/server mismatch) | Validation errors for valid-looking queries | Clients get "field not found" after deploy | Version schema, use persisted queries, coordinate deploys |
Performance Characteristics
| Phase | Latency (typical query) | CPU Cost | Optimization |
|---|---|---|---|
| Parsing | 10-100 Β΅s | Low | Persisted queries (skip parsing) |
| Validation | 50-200 Β΅s | Low | Persisted queries (skip validation) |
| Execution (resolvers) | 1-500 ms | Depends on resolvers | DataLoader, caching, query planning |
| Serialization (JSON) | 10-100 Β΅s | Low | Streaming serialization for large responses |
The total overhead of GraphQL's parsing and validation phases is negligible (under 1ms for typical queries). Nearly all latency is in the resolver execution phase, which is determined by your data sources, not GraphQL itself.
| Query Pattern | Without DataLoader | With DataLoader | Improvement |
|---|---|---|---|
| 10 users + company | 11 queries | 2 queries | 5.5x fewer |
| 10 users + 10 posts each + author | 111 queries | 3 queries | 37x fewer |
| 50 items, 3 nested levels | 50 + 2500 + 125000 | 4 queries | ~31,000x fewer |
How This Compares to Alternatives
| Feature | GraphQL | REST | gRPC | tRPC |
|---|---|---|---|---|
| Client-defined response shape | Yes | No (server-defined) | No (protobuf) | Partial |
| Type safety | Schema + validation | OpenAPI (optional) | Protobuf (strong) | TypeScript (strong) |
| Batching built-in | No (needs DataLoader) | No | No | No |
| Streaming | Subscriptions (WebSocket) | SSE, WebSocket | Bidirectional streaming | Subscriptions |
| Caching | Complex (POST, variable shapes) | Simple (HTTP caching) | No HTTP caching | No HTTP caching |
| Over/under-fetching | Eliminated | Common problem | Eliminated (fixed schema) | Eliminated |
| Browser support | Native (HTTP POST) | Native | gRPC-Web required | Native |
I reach for GraphQL when the frontend needs flexibility in what data it fetches (mobile vs web vs admin), when multiple teams consume the same API, or when the schema is complex enough that client-defined selections save significant bandwidth. I use REST for simple CRUD APIs, gRPC for internal service-to-service communication, and tRPC for full-stack TypeScript applications where both client and server share the same codebase.
Interview Cheat Sheet
- When asked "how does GraphQL execute a query?": "Four phases: parse the string into an AST, validate against the schema, execute by walking the AST depth-first and calling resolvers per field, then serialize the result to match the query shape."
- When asked about the N+1 problem: "Each resolver runs independently. A list of N items where each has a relationship field triggers N+1 queries. DataLoader solves this by collecting keys within a single event loop tick and issuing one batched query."
- When asked about DataLoader: "DataLoader is a per-request batching and caching utility. You create a new instance per request in the context. It deduplicates keys and batches them into a single call to the data source."
- When asked about validation: "GraphQL validates every query against the schema before execution. It checks field existence, argument types, fragment compatibility, and directive placement. No resolver runs if validation fails."
- When asked about security: "Three layers: query depth limiting (cap at 7-10), complexity analysis (assign costs to fields, reject expensive queries), and persisted queries (whitelist allowed queries, reject ad-hoc)."
- When asked about subscriptions: "Subscriptions use WebSocket transport. The client registers a subscription query. When a relevant event fires, the server executes the selection set against the event payload and pushes the result."
- When asked about caching: "GraphQL is hard to cache at the HTTP level because every request is a POST with a unique body. Use per-field caching (Redis), response caching (keyed on query hash + variables), or CDN caching with persisted query hashes as GET parameters."
- When asked about mutations vs queries: "Queries execute in parallel (sibling fields resolve concurrently). Mutations execute serially in document order because they have side effects and order matters."
- When asked about fragments: "Fragments are reusable selections. Inline fragments with type conditions (... on User) are used for union/interface types. The executor calls resolveType to determine the concrete type before resolving fragment fields."
Test Your Understanding
Quick Recap
- GraphQL execution has four phases: lexing/parsing (string to AST), validation (AST against schema), execution (resolver tree traversal), and serialization (result to JSON).
- The resolver tree mirrors the query shape. Each field maps to a resolver function that receives the parent's return value.
- Default resolvers read
parent[fieldName], so you only write custom resolvers for fields that need data fetching or computation. - The N+1 problem occurs because resolvers are independent. DataLoader solves it by batching and deduplicating within a single event loop tick.
- DataLoader must be per-request (created in the context factory) because it caches results internally.
- Mutations execute serially, queries execute sibling fields in parallel. This is by spec.
- Query security requires three layers: depth limiting, complexity analysis, and persisted queries.
- Cursor-based pagination (Relay spec) is stable under concurrent writes and performs better than offset pagination for large datasets.
Related Concepts
- REST API Design: GraphQL was created to solve REST's over-fetching and under-fetching problems. Understanding REST's strengths (HTTP caching, simplicity) helps you choose between them.
- gRPC: For service-to-service communication, gRPC with Protocol Buffers often outperforms GraphQL because it skips the parsing/validation overhead and uses binary serialization.
- Database Query Planning: GraphQL's resolver execution is analogous to a database query planner. The "query plan" is the resolver tree, and DataLoader is the equivalent of a batched index scan.
- Event-Driven Architecture: GraphQL subscriptions are a thin wrapper around pub/sub. Understanding event-driven patterns helps you design efficient subscription schemas.
- API Gateway Patterns: In microservice architectures, GraphQL often sits as a gateway that federates multiple service schemas. Apollo Federation and schema stitching are the two main approaches.