Guide contents

Lesson 12 of 129

GraphQL

GraphQL is a query language for APIs and a runtime for executing queries against a typed schema. A client sends a query describing exactly the fields it needs, and the server returns a JSON response with that shape. It was designed to solve over-fetching (receiving more data than needed), under-fetching (needing several requests to assemble a view), and the coordination cost of many client-specific REST endpoints.

Schema

A GraphQL service publishes a schema written in the Schema Definition Language. The schema is a strongly typed graph of object types and their fields.

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

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

type Query {
  user(id: ID!): User
}

type Mutation {
  createPost(title: String!, authorId: ID!): Post!
}

type Subscription {
  postCreated: Post!
}

! marks a non-null field. [Post!]! is a non-null list of non-null posts. Scalars include Int, Float, String, Boolean, and ID. The schema also supports enums, interfaces, unions, and input types. The schema is introspectable, which enables documentation, code generation, and IDE tooling.

Operations

  • Query: reads data. Fields in a query can be resolved in parallel.
  • Mutation: writes data. Top-level mutation fields run serially in the order given, so later ones can depend on earlier ones.
  • Subscription: a long-lived operation where the server pushes results when events occur, usually over WebSockets (see WebSockets, SSE, and long polling).

A query and its result:

Text
query {
  user(id: "7") {
    name
    posts(first: 2) { title }
  }
}
JSON
{ "data": { "user": { "name": "Ada", "posts": [ { "title": "A" }, { "title": "B" } ] } } }

Queries can take variables, so the query text stays constant while values change. The client sends the text and a separate variables object, for example query GetUser($id: ID!) { user(id: $id) { name } } with {"id": "7"}. A fragment names a reusable set of fields that can be included in several places in a query.

All operations normally go to a single endpoint (POST /graphql) with the query and variables in the body. The response is always shaped like the query, and errors are reported in an errors array alongside partial data. HTTP status is often 200 even when some fields failed, which affects monitoring.

Execution: resolvers

Each field on a type has a resolver, a function that returns the field's value. The server executes a query by walking the selection set: it calls the resolver for the root field, then for each requested subfield of the result, recursively. Resolvers can fetch from databases, other services, or caches, so GraphQL is often a layer in front of several backends.

flowchart TD
    C["Client query"] --> G["GraphQL server: parse, validate, execute"]
    G --> R1["Resolver: Query.user"]
    R1 --> R2["Resolver: User.posts"]
    R2 --> R3["Resolver: Post.author"]
    R1 --> DB1["User service or DB"]
    R2 --> DB2["Post service or DB"]
    R3 --> DB1

Validation checks the query against the schema before execution, so invalid fields are rejected without running resolvers.

The N+1 problem and DataLoader

Naive execution causes the N+1 query problem. Consider a query that returns 50 posts and each post's author:

  1. One query fetches 50 posts.
  2. The Post.author resolver runs once per post, issuing 50 separate lookups.

That is 1 + 50 = 51 backend calls instead of 2.

DataLoader is the standard remedy. It is a per-request helper that:

  • Batches: collects all keys requested by resolvers during one tick of the event loop (or execution phase) and calls one batch function, such as SELECT ... WHERE id IN (...).
  • Caches within the request: repeated loads of the same key return the same result.

Author lookups become one batched query. Batching must be scoped per request so that caches do not leak data between users.

sequenceDiagram
    participant E as Executor
    participant L as DataLoader (per request)
    participant D as Database
    E->>L: load(author 1)
    E->>L: load(author 2)
    E->>L: load(author 1)
    Note over L: Keys collected in one tick, duplicates merged
    L->>D: SELECT ... WHERE id IN (1, 2)
    D-->>L: 2 rows
    L-->>E: results for each load call

Caching difficulties

REST benefits from HTTP caching, because each URL identifies a resource and GET responses carry cache headers. GraphQL breaks this model:

  • Most requests are POST to one URL, which HTTP caches and CDNs do not cache by default.
  • Every query can request a different combination of fields, so full responses have low reuse.

Mitigations:

  • Persisted queries: clients send a hash identifying a pre-registered query instead of query text. Using GET with the hash makes responses cacheable at a CDN and reduces request size.
  • Client-side normalized caches (Apollo Client, Relay) store objects by type and ID and update them across queries.
  • Server-side caching of resolvers or backend calls, and response caching hints per field.

Other operational concerns

  • Query cost and abuse: clients can send deeply nested or very broad queries. Protections include depth limits, complexity or cost analysis, timeouts, pagination limits, and allow-listing persisted queries. Per-request rate limiting by request count is insufficient because requests vary widely in cost.
  • Authorization must be enforced per field or in the data layer, not only at the top-level operation, since any field may be reachable through many paths.
  • Errors and monitoring: partial failures inside a 200 response need dedicated metrics.
  • Schema evolution: fields are added freely; removal uses a @deprecated marker and usage tracking before deletion, which avoids version numbers in URLs.
  • Federation: in a microservices system, a gateway composes one graph from subgraphs owned by different services (Apollo Federation is one implementation). See BFF and API composition.
  • Pagination: connections with cursors (the Relay convention of edges, node, pageInfo) are typical. See pagination and idempotency.

GraphQL versus REST

Aspect GraphQL REST
Endpoints Usually one Many, per resource
Response shape Chosen by the client Fixed by the server
Over/under-fetching Avoided by design Common
Typing Built-in schema and introspection Optional (OpenAPI)
HTTP caching Hard Native
Error signalling errors array, often HTTP 200 HTTP status codes
Server complexity Higher (resolvers, cost control, batching) Lower
File upload, streaming Needs extensions Straightforward
Fit Many client types with different data needs, aggregating several backends Simple resource APIs, public APIs, cache-heavy reads

GraphQL suits clients with varied data needs, such as mobile and web apps that display different subsets of the same data. It is less suitable for simple CRUD services, heavily cached public content, or large binary transfers.

Interview questions

  • What is the N+1 problem in GraphQL and how is it fixed? A list field's child resolver runs once per item, causing one backend call per item. DataLoader batches the keys collected during a request into a single call and caches within the request.
  • Why is caching harder than in REST? Requests are typically POSTs to a single URL with client-defined shapes, so URL-keyed HTTP caches do not apply. Persisted queries with GET, and normalized client caches, restore much of it.
  • How do you protect a GraphQL API from expensive queries? Limit query depth and complexity, cap page sizes, set timeouts, and allow only persisted queries for untrusted clients.
  • Why are mutations executed serially? Mutations have side effects, so top-level mutation fields run in order to make outcomes predictable, unlike query fields, which may run in parallel.