All articles
APIsGraphQLSystem Design14 min read

GraphQL Explained: Schema, Resolvers, N+1 and When Not To Use It

How GraphQL works end to end — schema, queries, mutations, subscriptions, resolver execution, DataLoader batching, caching strategies, query cost limiting and federation — plus the cases where REST is the better answer.

The problem GraphQL solves

A mobile order screen needs the order, the customer's name and tier, the line items, and a thumbnail per product. With REST that is four or five round trips, each returning far more data than the screen uses. On a 4G connection with 150ms RTT, the network is the product's performance.

  • Over-fetching — the endpoint returns 40 fields; the screen renders 4.
  • Under-fetching — you need related data, so you call again, in a loop.
  • Endpoint sprawl/orders/{id}/full, /orders/{id}/summary, one per screen, each a backend release.

GraphQL's answer: one endpoint, a typed graph of your domain, and the client sends the exact shape it wants.

The schema is the contract

type Order {
  id: ID!
  total: Money!
  status: OrderStatus!
  customer: Customer!
  items: [OrderItem!]!
}

type OrderItem { sku: String!  qty: Int!  product: Product! }
enum OrderStatus { PENDING PAID SHIPPED CANCELLED }

type Query {
  order(id: ID!): Order
  orders(status: OrderStatus, first: Int = 20, after: String): OrderConnection!
}

type Mutation {
  cancelOrder(id: ID!, reason: String): CancelOrderPayload!
}
A schema in SDL. This is simultaneously the docs, the validation and the type source.
  • ! means non-nullable. Overusing it is dangerous: a null in a non-null field nulls out its whole parent chain.
  • Introspection means clients get autocomplete and generated types for free.
  • Adding fields is always backward compatible — which is why GraphQL APIs rarely version.

Queries, mutations, subscriptions

# Query — fields resolve in parallel
query GetOrder($id: ID!) {
  order(id: $id) { id total customer { name tier } items { sku qty } }
}

# Mutation — top-level fields resolve in series
mutation Cancel($id: ID!) {
  cancelOrder(id: $id, reason: "duplicate") {
    order { id status }
    errors { field message }
  }
}

# Subscription — a long-lived stream
subscription OnStatus($id: ID!) {
  orderStatusChanged(id: $id) { id status updatedAt }
}
Return errors as data in mutations
Business failures (“card declined”, “order already shipped”) belong in the payload type as typed fields, not in the top-level errors array. That way clients get them type-safely and the transport-level errors array stays reserved for genuine faults.

How a query actually executes

  1. Parse the query into an AST.
  2. Validate it against the schema — unknown fields and wrong types fail here, before execution.
  3. Execute the resolver tree level by level, passing each field's result as the parent of the next.
  4. Serialise the response in exactly the shape the query requested.
Query.order1 DB callorder.totalscalar — freeorder.customer1 DB callorder.items1 DB call → 20 rowsitems[].product × 2020 calls → 1 with DataLoaderResolvers run per field, level by level. Sibling fields at the same depth are the batching opportunity.
Every field is a function. That is the elegance and the performance trap in one sentence.
const resolvers = {
  Query: {
    order: (_parent, { id }, ctx) => ctx.db.orders.byId(id),
  },
  Order: {
    customer: (order, _args, ctx) => ctx.loaders.customer.load(order.customerId),
    items:    (order, _args, ctx) => ctx.loaders.itemsByOrder.load(order.id),
  },
};
A resolver receives parent, args, context and info.

The N+1 problem and DataLoader

Twenty line items each resolve product. Naively that is twenty SELECT ... WHERE id = ? queries for one screen. This is the single most common way a GraphQL API becomes slower than the REST API it replaced.

import DataLoader from "dataloader";

const productLoader = new DataLoader(async (ids: readonly string[]) => {
  const rows = await db.products.whereIn("id", ids);   // ONE query
  const byId = new Map(rows.map(r => [r.id, r]));
  return ids.map(id => byId.get(id) ?? null);          // order must match input
});

// create per request — never share across users, it is a cache
DataLoader batches within a tick and dedupes within a request.
  • The returned array must be the same length and order as the keys, nulls included.
  • Instantiate loaders per request in the context, or you will leak one user's data to another.
  • Loaders fix N+1 within a level; deeply nested queries still fan out per level.

Caching without HTTP caching

Every request is a POST to /graphql with a distinct body, so CDNs and browsers cannot cache it. You replace that layer with three others.

LayerMechanismNotes
ClientNormalised store (Apollo, urql, Relay)Cache by type + id; requires stable ids
EdgePersisted queries via GETHash the query, send ?id=hash — now CDN-cacheable
ServerPer-resolver / entity cache in RedisCache the objects, not the responses
DatabaseDataLoader request-scoped cacheDedupes within one operation only

Persisted queries are the highest-leverage move: the client ships a hash instead of a query, which makes requests small, cacheable over GET, and closes off arbitrary-query attacks in one step.

Query cost, depth and DoS

A public GraphQL endpoint lets anyone write the query. A recursive one can be catastrophic.

query Bomb {
  order(id: "1") { customer { orders { items { product { reviews {
    author { orders { items { product { title } } } }
  } } } } } }
}
A perfectly valid query that can take down a server.
  • Depth limiting — reject beyond ~10 levels.
  • Complexity scoring — assign a cost per field, multiply by list sizes, enforce a per-request budget.
  • Pagination is mandatory on every list field; no unbounded first.
  • Disable introspection in production for non-public APIs.
  • Timeouts per resolver and per operation.
  • Authorise per field, not per endpoint — the graph lets clients reach objects by paths you did not anticipate.

Errors and partial data

{
  "data": { "order": { "id": "8812", "customer": null } },
  "errors": [{
    "message": "Customer service unavailable",
    "path": ["order", "customer"],
    "extensions": { "code": "UPSTREAM_UNAVAILABLE", "requestId": "3f9a" }
  }]
}

GraphQL returns HTTP 200 with partial data plus an errors array. That is deliberate — one failing field should not discard the rest of the screen — but it means your monitoring must read errors, not just status codes, or your dashboards will show a permanently healthy service.

Federation and the BFF pattern

  • BFF (backend for frontend) — a thin GraphQL layer that fans out to existing REST or gRPC services. Lowest risk, and where most teams should start.
  • Federation — each service owns part of the schema and a gateway composes them into one graph. Powerful at organisational scale, and it introduces a gateway that is now on every request path.
  • Schema stitching — the older manual approach; prefer federation.

When not to use GraphQL

SituationBetter choiceWhy
Simple CRUD with a few consumersRESTGraphQL's setup cost buys you nothing
Public API for unknown clientsREST + OpenAPICacheable at the CDN, no query-cost attack surface
High-volume service-to-servicegRPCBinary payloads and far lower per-call overhead
File uploads and downloadsPlain HTTPGraphQL multipart uploads are an awkward bolt-on
Heavy read caching at the edgeRESTETag + CDN is free; you would rebuild it by hand

Interview framing

“The mobile clients hit a GraphQL BFF so an order screen is one round trip instead of five, with no over-fetching on a slow network. Resolvers use per-request DataLoaders so nested fields batch into single queries instead of N+1. Because HTTP caching does not apply, we use persisted queries over GET for CDN caching plus a normalised client cache, and we enforce depth and complexity limits with mandatory pagination so a hostile query cannot exhaust the service. Business failures come back as typed fields in the mutation payload; the errors array is reserved for real faults and is what our alerting reads.”

Keep reading

Suggested next articles based on this one.

Design it, don't just read it.

Practise LLD and system design problems with structured rubrics and AI feedback.

Start practising free