Skip to content
yutils

How GraphQL Actually Works

What GraphQL does that REST doesn't — schema-first design, resolver trees, the N+1 problem and how DataLoader solves it, mutations, subscriptions, federation, and when GraphQL is overkill.

~9 min read

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 pegged

DataLoader — 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 queries

DataLoader 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 client

Subscriptions 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 metadata

What 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 response

Standard at Netflix / Walmart scale. Small teams stay monolithic.

REST vs GraphQL — at a glance

RESTGraphQL
EndpointsOne per resource (dozens)One (/graphql)
Over-fetchingCommonNone (exactly what was asked)
Under-fetchingCommon (multiple calls)None (one query)
Versioning/v1, /v2Schema evolution + @deprecated
HTTP cachingAutomatic (GET URL)Hard (POST). Apollo client cache instead
ToolingOpenAPI / PostmanGraphiQL / Apollo Studio
Learning curveKnow HTTPSchema + resolvers + N+1 awareness
File uploadsNatural (multipart)graphql-upload spec separately
Maturity30 years10 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

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.
Back to guides