REST gives you "one endpoint = one shape." To render "user name + post list + 5 friends" you make 3-4 round trips or over-fetch. GraphQL lets the client ask for exactly the shape it wants — one endpoint, one query, one response. How does that actually work? This guide walks through GraphQL's schema, resolver trees, the famous N+1 problem and how DataLoader fixes it, mutations / subscriptions / federation, and when GraphQL is overkill.
REST vs GraphQL — one quick example
# REST — multiple endpoints
GET /users/42 → user info
GET /users/42/posts → user's posts
GET /users/42/friends?limit=5 → 5 friends
3 round trips + plenty of unused fields per response
# GraphQL — one query
POST /graphql
{
user(id: 42) {
name
posts {
title
createdAt
}
friends(limit: 5) {
name
avatar
}
}
}
One round trip. Exactly the requested fields.Schema first
GraphQL starts with the schema, written in SDL:
type User {
id: ID!
name: String!
email: String
posts: [Post!]!
friends(limit: Int): [User!]!
}
type Post {
id: ID!
title: String!
content: String
author: User!
createdAt: DateTime!
}
type Query {
user(id: ID!): User
posts(limit: Int = 10): [Post!]!
}
type Mutation {
createPost(title: String!, content: String): Post!
}The schema is the contract. ! means non-nullable. [Post!]! means an empty array is allowed, but not null.
Resolvers — one function per field
const resolvers = {
Query: {
user: async (_, {id}) => {
return db.users.findById(id);
},
},
User: {
name: (user) => user.name, // default behavior (direct mapping)
posts: async (user) => {
return db.posts.findByUserId(user.id);
},
friends: async (user, {limit}) => {
return db.friendships.findFriends(user.id, limit);
},
},
Post: {
author: async (post) => {
return db.users.findById(post.userId);
},
},
};Each field has a resolver. Field-level lazy execution — only requested fields call their resolver.
Executing a query — the resolver tree
Query:
{
user(id: 1) {
name
posts {
title
author {
name
}
}
}
}
Execution:
1. Query.user(id: 1) → User{id:1, name:"Alice", ...}
2. User.name → "Alice" (default field resolver, direct mapping)
3. User.posts → [Post1, Post2, Post3]
4. for each Post:
- Post.title → direct mapping
- Post.author → User{id:1, name:"Alice"} ← same user
- User.name → "Alice"Result:
{
"data": {
"user": {
"name": "Alice",
"posts": [
{"title": "...", "author": {"name": "Alice"}},
{"title": "...", "author": {"name": "Alice"}},
{"title": "...", "author": {"name": "Alice"}}
]
}
}
}The N+1 problem — GraphQL's biggest trap
Query:
{
posts { ← 1 query
title
author { name } ← 1 query per post → 10 posts = 10 queries
}
}
Naïve execution:
1. db.posts.findAll() → 10 posts
2. For each post:
db.users.findById(p.userId) → 10 queries
Total: 11 queries (1 + N)
Thousands of posts → thousands of queries → DB peggedDataLoader — batching + caching
import DataLoader from 'dataloader';
const userLoader = new DataLoader(async (userIds) => {
// Fetch all in one call
const users = await db.users.findByIds(userIds);
// Return in the same order as userIds
return userIds.map(id => users.find(u => u.id === id));
});
// in resolvers
Post: {
author: async (post) => {
return userLoader.load(post.userId);
// All loads in the same tick are batched
},
},
Result:
1. db.posts.findAll() → 10 posts
2. userLoader.load called 10 times → DataLoader batches → 1 query
db.users.findByIds([1,2,3,...]) → 10 users
Total: 2 queriesDataLoader collects every load(id) in a microtask and batches them. Adds per-request memoization too.
Mutations — writes
# Schema
type Mutation {
createPost(input: CreatePostInput!): Post!
updatePost(id: ID!, input: UpdatePostInput!): Post!
deletePost(id: ID!): Boolean!
}
input CreatePostInput {
title: String!
content: String
tags: [String!]
}
# Client
mutation {
createPost(input: {title: "Hello", content: "..."}) {
id
title
createdAt
}
}
# Server
{
"data": {
"createPost": {
"id": "abc123",
"title": "Hello",
"createdAt": "2026-05-23T..."
}
}
}Mutations share the same endpoint as queries. The difference — the server runs them sequentially (queries can be parallel).
Subscriptions — server push
# Schema
type Subscription {
postCreated: Post!
}
# Client
subscription {
postCreated {
id
title
author { name }
}
}
# Server
pubsub.publish('POST_CREATED', {postCreated: newPost});
→ pushes to every subscribed clientSubscriptions usually ride WebSockets via the graphql-ws protocol. SSE works too.
Introspection — the schema is queryable
# Special query
{
__schema {
types {
name
fields {
name
type { name }
}
}
}
}
→ returns the full schema metadataWhat you get:
- Auto-generated docs — GraphiQL / GraphQL Playground use it
- Type-safe clients — GraphQL Code Generator generates TypeScript types
- Rich tooling — Apollo / Relay / urql cache & optimize
Trade-off — disable introspection in production. Attackers otherwise enumerate your internal schema.
Federation — multi-service GraphQL
Tie multiple GraphQL services into one endpoint:
Service A (users):
type User @key(fields: "id") {
id: ID!
name: String!
}
Service B (posts):
type Post {
id: ID!
title: String!
author: User! ← User defined in Service A
}
extend type User @key(fields: "id") {
id: ID! @external
posts: [Post!]! ← Service B extends User with posts
}
Apollo Gateway / Mesh:
- Composes both schemas
- Routes parts of a query to the right service
- Merges results into one responseStandard at Netflix / Walmart scale. Small teams stay monolithic.
REST vs GraphQL — at a glance
| REST | GraphQL | |
|---|---|---|
| Endpoints | One per resource (dozens) | One (/graphql) |
| Over-fetching | Common | None (exactly what was asked) |
| Under-fetching | Common (multiple calls) | None (one query) |
| Versioning | /v1, /v2 | Schema evolution + @deprecated |
| HTTP caching | Automatic (GET URL) | Hard (POST). Apollo client cache instead |
| Tooling | OpenAPI / Postman | GraphiQL / Apollo Studio |
| Learning curve | Know HTTP | Schema + resolvers + N+1 awareness |
| File uploads | Natural (multipart) | graphql-upload spec separately |
| Maturity | 30 years | 10 years |
When GraphQL is overkill
- Simple CRUD apps — REST is faster and more familiar
- Public API — CDN caching favors REST
- File upload / streaming heavy — multipart REST fits
- Small team, single client — schema design is overhead
GraphQL shines when:
- You support mobile + web with different shapes
- Deep nested relationships dominate (REST round-trips hurt)
- Many microservices behind a unified API — federation
- BFF (Backend for Frontend) — tailored shapes per UI
Common pitfalls
1. Skipping DataLoader
Without it, production fires off N+1 queries and pegs the DB. Wrap every nested resolver with a DataLoader.
2. Complex-query DoS
# Attacker's query
{
users {
posts {
comments {
author {
friends {
posts {
comments {
...
}
}
}
}
}
}
}
}
→ Millions of rows. Server down.
Defenses:
- query depth limit (graphql-depth-limit)
- query complexity analysis (graphql-query-complexity)
- persisted queries (only pre-approved queries allowed)3. Circular fragments
fragment A on User { friends { ...A } }
↑ references itself
→ Infinite loop. Spec rejects it but a few implementations leaked.4. Everything nullable
Forget the ! and every field is nullable. Clients write defensive null checks everywhere. Use name: String! when you mean it.
5. Ambiguous error handling
# REST
GET /users/999 → 404 Not Found
# GraphQL
{
"data": {"user": null},
"errors": [{"message": "User not found"}]
}
→ HTTP 200 OK. Errors live in the response body. Clients must check both.References
- GraphQL specification — spec.graphql.org
- DataLoader (Facebook) — GitHub
- Apollo Federation — apollographql.com
- Why we built GraphQL (Facebook, 2015) — Meta Engineering
Summary
- GraphQL = client requests exactly the shape it wants. One endpoint, one query, one response.
- Schema-first via SDL — the contract between client and server.
- Resolvers = one function per field, evaluated lazily.
- N+1 is the biggest trap — DataLoader fixes it with batching + memoization.
- Mutations are writes (sequential). Subscriptions push over WebSocket.
- Introspection enables auto docs / type-safe clients / great tooling. Disable in production.
- Federation stitches multiple services into a unified API. Standard at scale.
- REST vs GraphQL — varied shapes / deep nesting / multiple clients favor GraphQL. Simple CRUD / public API / file uploads favor REST.