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!
}!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 }
}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
- Parse the query into an AST.
- Validate it against the schema — unknown fields and wrong types fail here, before execution.
- Execute the resolver tree level by level, passing each field's result as the parent of the next.
- Serialise the response in exactly the shape the query requested.
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),
},
};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- 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.
| Layer | Mechanism | Notes |
|---|---|---|
| Client | Normalised store (Apollo, urql, Relay) | Cache by type + id; requires stable ids |
| Edge | Persisted queries via GET | Hash the query, send ?id=hash — now CDN-cacheable |
| Server | Per-resolver / entity cache in Redis | Cache the objects, not the responses |
| Database | DataLoader request-scoped cache | Dedupes 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 } } } }
} } } } } }
}- 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
| Situation | Better choice | Why |
|---|---|---|
| Simple CRUD with a few consumers | REST | GraphQL's setup cost buys you nothing |
| Public API for unknown clients | REST + OpenAPI | Cacheable at the CDN, no query-cost attack surface |
| High-volume service-to-service | gRPC | Binary payloads and far lower per-call overhead |
| File uploads and downloads | Plain HTTP | GraphQL multipart uploads are an awkward bolt-on |
| Heavy read caching at the edge | REST | ETag + 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.”