GraphQL — Deep Dive
GraphQL looks simple from the outside. One endpoint, flexible queries, nice schema. But production GraphQL is a different story — it has a set of well-known footguns that catch teams who migrate from REST without understanding the execution model. This piece covers the internals that matter.
Execution Model: How a Query Actually Runs
When a GraphQL server receives a query, it runs through four phases:
- Parse — The query string is parsed into an AST (Abstract Syntax Tree)
- Validate — The AST is checked against the schema. Invalid field names, wrong types, depth limit violations — all caught here, before any data fetching
- Execute — Resolvers are called for each field
- Serialize — The result is serialized to JSON
The execute phase is where things get interesting. Every field in a GraphQL schema has a resolver function — a function responsible for returning that field’s value. The root query fields are resolved first, then their children, depth-first.
const resolvers = {
Query: {
user: (parent, args, context, info) => {
return db.users.findOne({ id: args.id });
}
},
User: {
posts: (parent, args, context, info) => {
// parent is the user object returned above
return db.posts.findAll({ authorId: parent.id });
}
}
}
The four resolver arguments:
parent— the result returned by the parent resolverargs— arguments passed to the field in the querycontext— shared across all resolvers in a request (auth token, DB connection, etc.)info— metadata about the current query (field name, return type, the full query AST)
The N+1 Problem (and Why It’ll Kill Your API)
This is the most important thing to understand about GraphQL performance and almost every team hits it.
Given this query:
query {
posts(last: 10) {
title
author {
name
}
}
}
A naive resolver implementation does this:
- Query DB for 10 posts → 1 SQL query
- For each post, query DB for the author → 10 SQL queries
11 queries instead of 2. With 100 posts, it’s 101 queries. This is the N+1 problem.
DataLoader is the standard fix, invented by Facebook alongside GraphQL. It batches resolver calls within a single event loop tick.
const DataLoader = require('dataloader');
const userLoader = new DataLoader(async (userIds) => {
// Called once with ALL userIds accumulated this tick
const users = await db.users.findAll({ id: { $in: userIds } });
// Must return results in same order as input ids
return userIds.map(id => users.find(u => u.id === id));
});
const resolvers = {
Post: {
author: (post) => userLoader.load(post.authorId)
}
}
Now 10 posts → 1 query for posts + 1 query for all 10 authors. DataLoader batches and caches per-request.
The key insight: DataLoader exploits Node.js’s event loop. All load() calls in the same tick get batched into one loadMany() call on the next tick.
Schema Design Patterns
Relay-style pagination — The most common pattern for lists. Uses cursor-based pagination instead of page numbers:
type PostConnection {
edges: [PostEdge!]!
pageInfo: PageInfo!
}
type PostEdge {
node: Post!
cursor: String!
}
type PageInfo {
hasNextPage: Boolean!
endCursor: String
}
This handles real-time data better than offset pagination (if items are inserted mid-stream, offset pagination skips or duplicates items; cursors don’t).
Input types — For mutations, always use input types rather than individual scalar arguments. They’re reusable and versionable:
input CreatePostInput {
title: String!
body: String!
tags: [String!]
}
type Mutation {
createPost(input: CreatePostInput!): Post!
}
Union types vs interfaces — Interfaces define shared fields; unions allow completely different types in one field. Classic use case: a search result that could be a User, a Post, or a Product.
union SearchResult = User | Post | Product
type Query {
search(query: String!): [SearchResult!]!
}
Clients use inline fragments to handle each type:
query {
search(query: "graphql") {
... on User { name }
... on Post { title }
... on Product { price }
}
}
Caching: The Hard Part
REST caches naturally because each URL maps to a specific resource. GET /users/123 is always cacheable by its URL. GraphQL’s single endpoint blows this up.
Approaches:
Persisted Queries — Instead of sending the full query string on every request, clients pre-register queries and send a hash. Apollo calls this APQ (Automatic Persisted Queries). Benefits: CDN-cacheable (GET request with hash in URL), smaller request payload, prevents arbitrary query execution in production.
Response caching — Libraries like graphql-response-cache (RedwoodJS), Stellate (formerly GraphCDN), or Apollo Server’s cache hints let you annotate types/fields with cache TTLs. The server caches the serialized response by query+variables hash.
Field-level caching — Using @cacheControl directives:
type User @cacheControl(maxAge: 300) {
id: ID!
name: String!
profileViews: Int @cacheControl(maxAge: 0) # Never cache
}
Security Considerations
Query depth limiting — A malicious client can craft deeply nested queries that explode your resolvers. Libraries like graphql-depth-limit cap this:
app.use('/graphql', graphqlHTTP({
validationRules: [depthLimit(10)]
}));
Query complexity analysis — Depth alone isn’t enough. A shallow but wide query (requesting 100 fields on 100 objects) can be expensive. Libraries assign cost values to fields and reject queries that exceed a budget.
Disabling introspection in production — The schema introspection system is great for development but exposes your entire API surface to attackers. Most teams disable it in production:
const server = new ApolloServer({
introspection: process.env.NODE_ENV !== 'production'
});
Authorization — The most common mistake is handling auth in resolvers. If a field is requested but the user can’t see it, the resolver silently returns null, but the field was still resolved (potentially hitting your DB). Better pattern: use a library like graphql-shield to declare permission rules on the schema level, and reject unauthorized fields before execution.
Federation: GraphQL at Scale
When a company has multiple teams and services, a single monolithic GraphQL schema becomes a bottleneck. Apollo Federation solves this by splitting the schema across services while keeping a unified API.
Each service declares its own part of the schema and which fields it “owns”. A gateway stitches them together at request time. Shopify’s API, for example, runs a federated GraphQL architecture across dozens of internal services.
The alternative — schema stitching (manually merging schemas in the gateway) — is older and more fragile. Federation is now the standard for multi-team GraphQL.
REST vs GraphQL: The Honest Comparison
| REST | GraphQL | |
|---|---|---|
| Caching | Easy (HTTP) | Hard (requires tooling) |
| File uploads | Easy (multipart) | Awkward |
| Discoverability | OpenAPI/Swagger | Introspection |
| Learning curve | Low | Medium |
| Over/underfetching | Common | Solved |
| Real-time | Webhooks/SSE | Subscriptions built-in |
| Error handling | HTTP status codes | Always 200, errors in body |
The “always 200” error handling is one of GraphQL’s genuine rough edges. A partial response (some fields resolved, some errored) returns HTTP 200 with an errors array in the body. This breaks standard monitoring tools that watch for 4xx/5xx responses.
One thing to remember: The N+1 problem is non-negotiable — if you ship a GraphQL API without DataLoader and your schema has any nested relationships, you will have performance problems at scale. It’s not optional complexity, it’s table stakes.
See Also
- Apis What is an API? Think of it as a waiter who takes your order and brings back exactly what you asked for.
- Encryption Encryption explained: how your messages and passwords stay secret even when strangers can see them.
- Git Why do millions of programmers obsess over a tool that saves old versions of their work? Because without it, one bad day can delete months of effort.