GraphQL Isn't Magic: How ASTs, Resolvers, and DataLoaders Actually Work Under the Hood
Ask almost any frontend developer why they love GraphQL, and you will hear a familiar promise: "You just ask for the exact fields you need, and the server returns exactly what you asked for. No over-fetching, no under-f
Ask almost any frontend developer why they love GraphQL, and you will hear a familiar promise:
"You just ask for the exact fields you need, and the server returns exactly what you asked for. No over-fetching, no under-fetching, no multiple roundtrips."
It sounds like pure sorcery. You write a neat JSON-like query in your frontend codebase:
query GetUserProfile {
user(id: "usr_42") {
name
email
posts {
title
comments {
text
author {
name
}
}
}
}
}
You hit send. In a single network request, you get back users, their posts, their comments, and the comment authors, all perfectly nested in a clean JSON object.
Because it feels so seamless from the client side, many developers develop an intuitive mental model: they assume GraphQL is a smart query planner, something like an intelligent SQL optimizer that inspects the request, constructs a series of database joins, and returns the result.
Then the application goes into production.
Traffic ramps up. Suddenly, database CPU spikes to 100 percent. The connection pool runs dry. A single client query requesting twenty posts and their authors quietly fires seventy-one distinct SQL queries to PostgreSQL in less than ten milliseconds. Latency surges from fifty milliseconds to four seconds. The backend team scrambles to figure out why a simple profile page brought down the database server.
GraphQL is not an automated query planner. It is not an ORM, and it knows absolutely nothing about your database tables, foreign keys, or indexes.
GraphQL is a runtime specification and execution engine that recursively evaluates an Abstract Syntax Tree by invoking isolated resolver functions.
Once you understand what happens beneath the syntax, you can predict performance bottlenecks before they happen, write clean server architectures, and stop treating GraphQL like a black box.
Here is how the engine actually works under the hood.
Phase 1: From Raw String to AST (The Ingestion Pipeline)
When a client sends a GraphQL request, it arrives at your server as an ordinary HTTP POST request with a JSON payload:
{
"query": "query GetUser { user(id: \"42\") { name email } }",
"variables": {}
}
Notice that the query is simply a plain text string. Your server cannot execute a string directly. Before any business logic runs, the GraphQL engine pushes that string through three strict phases: Lexing, Parsing, and Validation.
[ Incoming HTTP POST Request ]
│
▼
1. Lexing (Tokenizing)
│
▼
2. Parsing (AST Generation)
│
▼
3. Schema Validation (Type Checking)
│
▼
4. Execution Phase (Resolver Tree)
1. Lexical Analysis (Lexing)
The lexer scans the raw query string character by character and groups them into lexical tokens: keywords, names, punctuation marks (such as curly braces and colons), and literal values.
For example, { user(id: "42") } is converted into an array of distinct tokens:
PUNCTUATOR: {NAME: userPUNCTUATOR: (NAME: idPUNCTUATOR: :STRING: "42"PUNCTUATOR: )PUNCTUATOR: }
2. Syntactic Parsing (Building the AST)
Next, the parser takes the stream of tokens and constructs an Abstract Syntax Tree (AST). An AST is a deeply nested JavaScript object that represents the grammatical structure of your query.
Here is a simplified view of what { user { name } } looks like as an AST:
{
kind: "Document",
definitions: [
{
kind: "OperationDefinition",
operation: "query",
selectionSet: {
kind: "SelectionSet",
selections: [
{
kind: "Field",
name: { kind: "Name", value: "user" },
selectionSet: {
kind: "SelectionSet",
selections: [
{
kind: "Field",
name: { kind: "Name", value: "name" }
}
]
}
}
]
}
}
]
}
This tree structure is crucial: the execution engine does not care about the text formatting, tabs, or whitespace of the original query. It only navigates this hierarchical AST.
3. Schema Validation
Before executing a single line of backend code, the engine validates the AST against your defined GraphQL Schema (the Type System).
It answers critical questions statically:
- Does the
userfield exist on the rootQuerytype? - Does the
userfield accept anidargument? - Is the provided argument type valid (e.g.
Stringinstead ofInt)? - Does the
Userobject actually have a field namedname?
If a client requests a field that does not exist in the schema, the engine halts immediately during validation. It never touches your database, never runs your authentication middleware, and never invokes a resolver. It returns an HTTP 200 response with a top-level errors array explaining the exact syntax or validation mismatch.
Phase 2: Resolvers (How Execution Actually Works)
Once the query passes validation, the engine begins the Execution Phase. This is where your custom application code runs.
In GraphQL, every single field in your query corresponds to an underlying function called a Resolver.
The Anatomy of a Resolver
A resolver function always receives four arguments, regardless of language or framework:
const userResolver = (parent, args, context, info) => {
// your custom fetching logic
};
Let's demystify these four parameters:
-
parent(orroot): The resolved value returned by this field's immediate parent resolver. For top-level query fields, this is usuallyundefined. For nested fields, it is the parent object. -
args: An object containing all arguments passed to this field in the query (for example,{ id: "42" }). -
context: A shared object passed across every single resolver during a single request. This is where you store database connections, current authenticated user sessions, and caching instances. -
info: A low-level object containing AST metadata about the current field, path, and incoming query structure.
Depth-First Recursive Execution
How does the engine traverse the AST? It uses a Depth-First Search (DFS) algorithm.
Consider this query:
query {
user(id: "42") {
name
profile {
bio
}
}
}
Here is the exact step-by-step sequence the engine executes:
Step 1: Execute Query.user(parent=undefined, args={ id: "42" })
│
▼ Returns: { id: "42", name: "Sarah Connor", profileId: "prof_99" }
│
Step 2: Concurrently resolve fields inside User selection set:
├─► Execute User.name(parent={ id: "42", name: "Sarah Connor", ... })
│ └─► Returns: "Sarah Connor"
│
└─► Execute User.profile(parent={ id: "42", name: "Sarah Connor", ... })
└─► Returns: { id: "prof_99", bio: "Protecting the future." }
│
▼
Step 3: Execute Profile.bio(parent={ id: "prof_99", bio: "..." })
└─► Returns: "Protecting the future."
The Default Resolver
You might wonder: "Do I have to write a resolver for every scalar field like name, email, and bio?"
No, because GraphQL engines provide a Default Resolver for any field you do not explicitly define. If you do not provide a resolver for name, the engine uses an internal fallback that behaves like this:
function defaultFieldResolver(parent, args, context, info) {
if (typeof parent === "object" && parent !== null) {
const property = parent[info.fieldName];
if (typeof property === "function") {
return property.call(parent, args, context, info);
}
return property;
}
return null;
}
If the parent object already has a property matching the field name, it just extracts it. If not, it returns null.
This recursive tree execution model is clean, elegant, and modular. But it hides an architectural landmine.
Phase 3: The Fatal Trap (The N+1 Query Problem)
Because each resolver is an isolated function responsible only for its own field, resolvers have zero awareness of what their sibling resolvers or parent resolvers are doing.
This architectural isolation leads directly to the single most notorious performance flaw in GraphQL: The N+1 Query Problem.
How the Trap Springs
Imagine an e-commerce platform displaying a list of twenty recent orders, along with the customer details for each order:
query GetRecentOrders {
recentOrders(limit: 20) {
id
totalAmount
customer {
id
name
email
}
}
}
Now look at how a typical beginner writes the resolver schema:
const resolvers = {
Query: {
recentOrders: async (parent, args, context) => {
// 1 Database Query: Fetch 20 orders
return await context.db.query("SELECT * FROM orders ORDER BY created_at DESC LIMIT $1", [args.limit]);
}
},
Order: {
customer: async (order, args, context) => {
// Runs once FOR EVERY SINGLE ORDER!
return await context.db.query("SELECT * FROM customers WHERE id = $1", [order.customerId]);
}
}
};
Let's count what happens in your database:
- The
Query.recentOrdersresolver fires 1 query:SELECT * FROM orders ORDER BY created_at DESC LIMIT 20;It returns an array of 20 order rows. - The engine iterates over the 20 orders and invokes the
Order.customerresolver for each item. - The server fires 20 individual database queries:
SELECT * FROM customers WHERE id = 101;SELECT * FROM customers WHERE id = 102;SELECT * FROM customers WHERE id = 103;- ...and so on, 20 times in a row.
Total database roundtrips: 1 + 20 = 21 queries.
If you change the limit to 100 orders, you trigger 101 queries. If each customer has a nested addresses field, you trigger another 100 queries. Before you realize it, a single HTTP request generates hundreds of database queries, choking your connection pool and dragging server throughput to a halt.
Phase 4: How DataLoader Saves the Day (Event Loop Magic)
To solve the N+1 problem without destroying the modular nature of resolvers, Facebook created a utility called DataLoader.
Most developers know that DataLoader "batches and caches" database requests. But very few understand the mechanical trick that makes it possible: JavaScript Event Loop Microtask Batching.
The Architectural Problem
Resolvers run asynchronously. How can individual resolvers, running on separate array items, know what other resolvers are about to request so they can combine their queries into a single SELECT * FROM customers WHERE id IN (...)?
If resolver 1 runs, how can it pause and wait for resolver 20 to declare its needs before making the database call?
The Microtask Queue Mechanism
DataLoader takes advantage of the fact that Node.js processes JavaScript execution in distinct ticks and microtask queues.
When a resolver asks DataLoader for an item:
customerLoader.load(order.customerId);
DataLoader does not immediately fire a database query. Instead, it does three things:
- It records the requested ID in an internal memory array (
[101]). - It creates and returns a pending JavaScript Promise to that specific resolver.
- It schedules a batch-clearing function to run on the next tick of the event loop using
process.nextTick()or microtask queues.
Because JavaScript runs synchronously within the current tick of the event loop, all twenty Order.customer resolvers execute their synchronous parts in the exact same event loop turn!
Tick 0 (Current Synchronous Call Stack):
Resolver 1 calls customerLoader.load(101) --> ID stored, Promise 1 returned
Resolver 2 calls customerLoader.load(102) --> ID stored, Promise 2 returned
Resolver 3 calls customerLoader.load(101) --> Deduplicated! Returns Promise 1
...
Resolver 20 calls customerLoader.load(120) --> ID stored, Promise 20 returned
Tick 1 (Microtask Phase - Call Stack Cleared):
DataLoader batch function fires:
"SELECT * FROM customers WHERE id IN (101, 102, ..., 120);"
Single SQL response arrives:
DataLoader matches returned rows to pending Promises and resolves each one!
Implementing a Real DataLoader
Here is what setting up a DataLoader looks like in practice:
import DataLoader from "dataloader";
// Define the batch function: receives an array of keys, must return an array of values in matching order
const batchGetCustomers = async (customerIds, db) => {
// customerIds = [101, 102, 120, ...]
const rows = await db.query(
"SELECT * FROM customers WHERE id = ANY($1)",
[customerIds]
);
// CRITICAL: DataLoader requires results to be returned in the EXACT same order as keys
const customerMap = new Map(rows.map(row => [row.id, row]));
return customerIds.map(id => customerMap.get(id) || null);
};
// Instantiated per request inside context!
export function createLoaders(db) {
return {
customerLoader: new DataLoader(ids => batchGetCustomers(ids, db))
};
}
Now, your resolver becomes clean and fast:
const resolvers = {
Order: {
customer: (order, args, context) => {
// Returns a promise that DataLoader resolves in a single batch query!
return context.loaders.customerLoader.load(order.customerId);
}
}
};
Instead of 21 queries, your server executes exactly 2 queries: one for the orders, and one batched lookup for all customers.
Important Rule: Always instantiate DataLoaders inside your request
context, never as global singletons. If you create a global DataLoader, it will cache database records across different users indefinitely, leading to massive memory leaks and critical security vulnerabilities where User A reads cached data belonging to User B.
Phase 5: The Security Dilemma (Circular Depth Attacks)
Because GraphQL gives clients the freedom to ask for whatever structure they want, it introduces a severe security vulnerability that does not exist in traditional REST APIs: Unbounded Query Depth.
If your schema allows users to query their friends, and each friend has friends, a malicious actor or buggy client can send this query:
query MaliciousDepthAttack {
user(id: "1") {
friends {
friends {
friends {
friends {
friends {
friends {
name
}
}
}
}
}
}
}
}
If left unprotected, your GraphQL engine will happily recurse down all seven levels, expanding exponentially. If each user has 50 friends, level 6 evaluates millions of resolver calls, instantly consuming server memory and CPU until the process crashes.
Production GraphQL servers defend against this using two complementary techniques:
1. Query Depth Limiting
Before allowing an AST to enter the execution phase, a depth-limiting validator traverses the AST and counts how many levels of nested selection sets exist. If the depth exceeds a safe threshold (typically 5 to 7 levels), the engine rejects the query during validation with an immediate error:
import depthLimit from "graphql-depth-limit";
import { createYoga } from "graphql-yoga";
const yoga = createYoga({
schema,
validationRules: [
depthLimit(5) // Rejects queries nested deeper than 5 levels
]
});
2. Query Cost Analysis
Depth alone is not enough. A query with a depth of only two levels could still ask for recentUsers(limit: 10000) { expensiveCalculatedField }, which is cheap in depth but catastrophic in computation cost.
Query Cost Analysis assigns a numeric weight to each field:
- Simple scalar fields (
name,email): Cost = 1 point. - Relational fields with pagination (
posts(limit: 50)): Cost = 50 * inner field costs.
The engine computes the total query score before execution. If the cost exceeds a predefined budget (for example, 500 points per request), the query is rejected immediately.
Phase 6: The Caching Conundrum (REST vs GraphQL)
In a conventional REST API, caching is built directly into the foundational fabric of the internet: the HTTP specification.
If you make an HTTP GET /api/users/42:
- Web browsers cache the response.
- Edge CDNs (Cloudflare, Fastly, CloudFront) cache the response based on
Cache-Controlheaders. - Reverse proxies return
304 Not ModifiedusingETagvalidation without touching application servers.
In GraphQL, almost everything breaks that paradigm:
- Every request is an HTTP POST: Standard web infrastructure and CDNs do not cache POST requests because POST is defined as non-idempotent by the HTTP specification.
-
Every request hits the same endpoint: Requests go to
/graphql. You cannot cache by URL path. -
HTTP Status Code 200 for Errors: Even if half your query fails due to a database exception, GraphQL returns HTTP 200 OK with a payload containing partial data and an
errorsarray.
How do production GraphQL architectures solve caching? Through two distinct layers: Persisted Queries at the Edge and Normalized Caches on the Client.
[ Edge Caching Strategy: Automatic Persisted Queries (APQ) ]
Client sends SHA256 Hash of Query via HTTP GET /graphql?hash=a7b3...
│
├─► CDN Cache Hit: Returns cached JSON immediately!
│
└─► CDN Cache Miss: Server executes query, stores in CDN, returns JSON.
[ Client-Side Caching Strategy: Normalized In-Memory Cache ]
Response JSON arrives with __typename and id:
User:usr_42 --> { name: "Sarah Connor" }
Post:post_88 --> { title: "Liquid Metal", authorRef: "User:usr_42" }
Graph is flattened into relational relational key-value store!
1. Automatic Persisted Queries (APQ)
To re-enable CDN caching, modern architectures use Persisted Queries:
- During the build step or initial request, the client computes a SHA-256 hash of the query string.
- The client sends a lightweight
GET /graphql?id=<hash>request instead of a bulky POST body. - Because it is a standard HTTP
GETrequest with a deterministic hash query parameter, edge CDNs can cache the entire response payload based on standardCache-Controlheaders!
2. Normalized Client Caches
On the client side (using Apollo Client, Urql, or Relay), the cache does not store raw server responses as arbitrary JSON blobs.
Instead, the client cache performs Normalization:
- It inspects every object in the returned JSON.
- It extracts the
__typenameandid(e.g.User:42). - It flattens the nested graph into a relational dictionary of unique entity keys.
If a profile update mutation updates User:42's name, every component across your entire application displaying User:42 automatically updates instantly, without needing a full refetch.
Phase 7: Building a Mini GraphQL Engine from Scratch
The best way to solidify your mental model of GraphQL is to build a stripped-down resolver engine yourself.
Here is a complete, runnable resolver evaluator in approximately thirty lines of vanilla JavaScript:
// A minimal schema with types and field resolvers
const schema = {
Query: {
user: (parent, args) => ({ id: args.id, name: "Sarah", role: "Engineer" })
},
User: {
company: (parent) => ({ name: "Skynet", domain: "defense.ai" })
}
};
// Simplified query AST representation
const queryAST = {
field: "user",
args: { id: "42" },
selectionSet: [
{ field: "name" },
{
field: "company",
selectionSet: [{ field: "domain" }]
}
]
};
// The recursive execution engine
async function executeQuery(ast, typeName, parentData = null) {
const typeResolvers = schema[typeName] || {};
const resolver = typeResolvers[ast.field] || ((parent) => parent?.[ast.field]);
// Resolve the current field
const resolvedValue = await resolver(parentData, ast.args);
// If there are no nested fields, return the scalar value
if (!ast.selectionSet || ast.selectionSet.length === 0) {
return resolvedValue;
}
// Determine the child type name (for demo purposes, capitalized field name)
const childTypeName = ast.field.charAt(0).toUpperCase() + ast.field.slice(1);
const result = {};
// Concurrently resolve all fields in the selection set
await Promise.all(
ast.selectionSet.map(async (childAST) => {
result[childAST.field] = await executeQuery(childAST, childTypeName, resolvedValue);
})
);
return result;
}
// Run it!
executeQuery(queryAST, "Query").then((output) => {
console.log("Resolved GraphQL Output:", JSON.stringify(output, null, 2));
});
Run that script with node mini-graphql.js and you will see:
{
"name": "Sarah",
"company": {
"domain": "defense.ai"
}
}
Look closely at what those thirty lines do:
- They recursively walk the tree.
- They invoke resolvers with the parent data.
- They fall back to default property accessors when no custom resolver is declared.
- They resolve children concurrently using
Promise.all.
There is no magic, no secret database connection, and no compiler sorcery. GraphQL is simply a structured recursive function evaluator that traverses a tree.
The Architectural Decision Matrix: When to Use GraphQL vs REST
GraphQL is a powerful tool, but it is not a silver bullet that should replace REST in every project. Choosing the wrong tool for your use case creates unnecessary operational overhead.
Here is an architectural comparison to help you choose the right paradigm:
| Architectural Dimension | Traditional REST API | GraphQL Architecture |
|---|---|---|
| Data Fetching Precision | Fixed endpoints; risk of over-fetching or under-fetching | Client requests exact fields; eliminates payload bloat |
| Network Roundtrips | Often requires multiple waterfall requests for related data | Single roundtrip retrieves nested relational trees |
| HTTP Caching | Native, effortless caching via URLs, CDNs, and browser caches | Requires Automatic Persisted Queries (APQ) or client normalization |
| Database Performance | Predictable query patterns; easy to optimize with SQL joins | Prone to N+1 problem unless mitigated by DataLoaders |
| API Versioning | Explicit versioning in URL (/v1/, /v2/) |
Continuous evolution; deprecated field directives |
| Security & DoS Protection | Standard rate limiting by IP and endpoint path | Requires Query Depth Limiting and Cost Complexity Analysis |
| File Uploads | Simple, native multipart/form-data support | Awkward workarounds or separate upload endpoints required |
| Best Used For | Microservice-to-microservice RPC, simple CRUD, public APIs | Complex frontend apps, mobile clients with low bandwidth, rapidly evolving UIs |
The Complete GraphQL Mental Model Cheat Sheet
Keep these core principles in mind whenever you design or debug a GraphQL system:
- GraphQL executes strings through an AST: Lexing tokenizes the string, parsing builds the syntax tree, and validation checks the tree against your schema before execution begins.
- Every field is a resolver: If you do not provide a resolver, the default resolver reads the property directly off the parent object.
- Resolvers are isolated by design: Because resolvers do not know what their peers are doing, nested queries naturally cause the N+1 database problem.
- DataLoader uses event loop ticks: It queues IDs synchronously during Tick 0, yields to the microtask queue, and dispatches a single batched database query in Tick 1.
- Always limit query depth and calculate cost: Never expose an unhardened GraphQL endpoint to the public internet without depth limiting and complexity budgeting.
-
Cache at the edge with APQ and normalize on the client: Use SHA-256 persisted queries to regain CDN caching, and let client libraries flatten your graph using
__typename:id.
Once you stop viewing GraphQL as a magical database client and start treating it as a recursive tree execution engine, the mysterious performance drops disappear. You know exactly when to reach for a DataLoader, how to protect your server from abusive queries, and how to build backends that remain fast, stable, and predictable under heavy production load.
If you have worked with GraphQL in production, you have almost certainly encountered a moment where a nested query brought your database or memory usage to its knees. How do you usually handle relational fetching in your team: do you rely strictly on DataLoaders, use query lookahead engines, or build purpose-built REST endpoints for high-throughput views? I would love to hear your experiences and architectural trade-offs in the comments below.
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.



