How GraphQL Works — Schemas, Queries, and the N+1 Problem

How GraphQL Works — Schemas, Queries, and the N+1 Problem

2026-03-24

GraphQL is a query language for APIs. Facebook created it in 2012 and open-sourced it in 2015 to solve a specific problem: mobile clients needed different data than web clients, and REST endpoints either returned too much data (over-fetching) or required too many requests (under-fetching).

With GraphQL, the client describes exactly what it needs, and the server returns exactly that — nothing more, nothing less, in a single request.

The Problem GraphQL Solves

Imagine a social media profile page. You need the user's name, their last 5 posts, and each post's comment count. With REST:

  1. GET /users/42 — returns the user (includes 20 fields you don't need on mobile)
  2. GET /users/42/posts?limit=5 — returns the posts
  3. GET /posts/101/comments/count, GET /posts/102/comments/count, ... — one request per post

That's 7 HTTP round trips. On a 200ms mobile connection, that's 1.4 seconds of latency just from the network — before the server even does any work.

With GraphQL, it's one request:

query {
  user(id: 42) {
    name
    posts(limit: 5) {
      title
      commentCount
    }
  }
}

One round trip. Only the fields you asked for. The response mirrors the shape of the query.

The Schema

Every GraphQL API starts with a schema — a typed definition of everything the API can do. The schema defines types, their fields, and how they relate:

type User {
  id: ID!
  name: String!
  email: String!
  posts(limit: Int): [Post!]!
}

type Post {
  id: ID!
  title: String!
  body: String!
  author: User!
  commentCount: Int!
}

type Query {
  user(id: ID!): User
  posts(limit: Int, offset: Int): [Post!]!
}

type Mutation {
  createPost(title: String!, body: String!): Post!
  deletePost(id: ID!): Boolean!
}

type Subscription {
  postCreated: Post!
}

Three root types define what the API can do:

  • Query — read data (like GET in REST)
  • Mutation — write data (like POST/PUT/DELETE in REST)
  • Subscription — real-time updates pushed from the server (typically over WebSockets)

The ! means non-nullable. [Post!]! means a non-null list of non-null posts. The schema is a contract between client and server — clients know exactly what fields exist and what types they return.

Resolvers — How Fields Are Fetched

Each field in the schema has a resolver — a function that returns the data for that field. When a query arrives, the GraphQL engine walks the query tree and calls the resolver for each field:

const resolvers = {
  Query: {
    user: (_, { id }) => db.users.findById(id),
  },
  User: {
    posts: (user, { limit }) => db.posts.findByAuthor(user.id, limit),
  },
  Post: {
    commentCount: (post) => db.comments.countByPost(post.id),
  },
};

The engine calls Query.user first. The result is passed to User.posts as the parent argument. Each post is passed to Post.commentCount. Resolvers compose — each one fetches just the data for its own field.

REST Multiple Requests vs GraphQL Single Query

REST (multiple requests)

GET /users/42 200ms GET /users/42/posts 200ms GET /posts/101/comments 200ms GET /posts/102/comments 200ms ... more requests

7 round trips = 1400ms

GraphQL (single query)

POST /graphql { user(id: 42) { name posts(limit: 5) { title commentCount } } }

1 round trip = 200ms

Same data, fewer round trips Client specifies shape. Server returns exact match.

The N+1 Problem

The composable resolver model has a trap. If the query asks for 50 users and each user's posts, the engine calls Query.users once (1 query), then User.posts once per user (50 queries). That's 51 database queries for one GraphQL request — the N+1 problem.

It gets worse. If each post also resolves its author, comment count, and tags, a single GraphQL query can trigger hundreds of database queries.

DataLoader is the standard solution. Instead of fetching one user's posts immediately, the resolver registers the user ID. At the end of the current execution tick, DataLoader collects all registered IDs and makes a single batched query:

// Without DataLoader: 50 queries
// SELECT * FROM posts WHERE author_id = 1
// SELECT * FROM posts WHERE author_id = 2
// ...

// With DataLoader: 1 query
// SELECT * FROM posts WHERE author_id IN (1, 2, 3, ..., 50)

DataLoader is not optional. Any production GraphQL server without batched data loading will hit database performance problems immediately.

When GraphQL Is Better Than REST

GraphQL wins when:

  • Clients need different views of the same data (mobile vs web vs watch)
  • The data graph is deeply nested (users → posts → comments → authors)
  • You want a single endpoint instead of dozens of REST routes
  • Strong typing and schema introspection matter (tooling, code generation)

REST wins when:

  • The API is simple CRUD with few relationships
  • HTTP caching matters (GET requests are cacheable by default; GraphQL POST requests are not)
  • You need file uploads (GraphQL has no native file upload spec)
  • The team is small and REST's simplicity reduces cognitive overhead

Common Pitfalls

Query complexity attacks. A malicious client can send deeply nested queries that explode server resources. Production GraphQL servers must enforce query depth limits, complexity scoring, and timeouts.

No HTTP caching. All GraphQL requests are POST to /graphql. HTTP caches (CDNs, browser cache) don't work by default. You need application-level caching (persisted queries, response caching by query hash).

Schema evolution. GraphQL doesn't have versioning — fields are deprecated, not removed. This requires discipline. A @deprecated directive signals intent, but removing a field breaks any client still using it.

GraphQL Over the Wire

GraphQL is transport-agnostic, but in practice it runs over HTTP. Queries are sent as POST requests with a JSON body containing the query string. Responses are JSON with a data field (and optionally an errors field). Subscriptions typically use WebSockets or Server-Sent Events for the persistent connection.

Next Steps