GraphQL
GraphQL lets clients ask for exactly the fields they need from one typed schema. Watch resolvers execute a nested query level by level, then see how the N+1 problem appears and how DataLoader batches it away.
🎯 What you'll learn
- One endpoint and a query language
- The schema as a typed contract; queries vs mutations
- Resolvers and execution
- N+1 and DataLoader, subscriptions and schema evolution
📚 Before you start
This topic assumes the REST-based model built up across the rest of this group (01-rest_fundamentals through 11-api_documentation). GraphQL is presented here as an alternative to that model, not a superset — this part covers the schema/type system, how a query becomes data, and the single-endpoint model. Part 2 covers the N+1 query problem and DataLoader batching, over/under-fetching compared to REST, subscriptions, and schema evolution.
1One Endpoint, a Query Language Instead of Many URLs
A REST API exposes many URLs, each returning a fixed shape (GET /orders/482 always returns the same fields). GraphQL exposes exactly one endpoint (conventionally POST /graphql), and the client's request body — the query — specifies exactly which fields it wants back:
query {
order(id: 482) {
id
status
customer {
name
}
}
}{
"data": {
"order": { "id": 482, "status": "confirmed", "customer": { "name": "Ana" } }
}
}The server never sends fields the client didn't ask for, and the client never has to make a second request to fetch customer.name — both problems that REST's fixed-shape responses run into (over-fetching and under-fetching, covered in depth in Part 2).
2The Schema: a Strongly-Typed Contract
Every field the API can ever return is declared up front, in the Schema Definition Language (SDL):
type Order {
id: ID!
status: OrderStatus!
total: Int!
customer: Customer!
items: [OrderItem!]!
}
enum OrderStatus {
PENDING
CONFIRMED
SHIPPED
CANCELLED
}
type Query {
order(id: ID!): Order
orders(customerId: ID!): [Order!]!
}! marks a field non-nullable — the server guarantees it's never null in a valid response (an "Order" always has a status, an Order field on Query may legitimately return null if no order with that id exists). This schema is the entire contract: a client can only request fields that exist on it, and a query requesting a field that doesn't exist fails before execution, at validation time — this is GraphQL's equivalent of REST's OpenAPI schema (backend/api-design/11-api_documentation), except it's enforced by the GraphQL engine itself at request time, not just documented separately.
3Queries vs. Mutations
Reads and writes are distinct root types, mirroring (loosely) REST's GET-is-safe vs POST/PUT/DELETE-changes-state distinction:
type Mutation {
cancelOrder(id: ID!): Order!
createOrder(input: CreateOrderInput!): Order!
}
input CreateOrderInput {
customerId: ID!
itemIds: [ID!]!
}mutation {
cancelOrder(id: 482) {
id
status
}
}A mutation's response can (and usually should) select fields back from the result of the write, in the same request — a client cancelling an order gets the updated status back in the same round trip, without a follow-up GET.
4Resolvers: Where Fields Actually Get Their Data
Every field in the schema is backed by a resolver function — the code that actually produces that field's value, given its parent object:
const resolvers = {
Query: {
order: async (_parent, args: { id: string }, ctx: Context) => {
return ctx.db.orders.findById(args.id); // resolves the Query.order field
},
},
Order: {
customer: async (parent: OrderRow, _args, ctx: Context) => {
return ctx.db.customers.findById(parent.customerId); // resolves Order.customer, given the Order already resolved
},
},
};The GraphQL engine walks the query, calling Query.order's resolver first (producing an Order row), then — because the query also asked for customer.name — calling Order.customer's resolver with that row as its parent argument, and so on recursively until every requested field has a resolver-produced value. This resolver-per-field execution model is the root cause of Part 2's N+1 problem: each nested resolver call is, by default, independent and unaware of its siblings.
⚠️ Common Pitfalls
- Treating the schema as optional documentation. Unlike a REST OpenAPI spec (which can silently drift from the code, per
backend/api-design/11-api_documentation), a GraphQL schema is enforced live — a query for a non-existent field is rejected before any resolver runs, which is a real guarantee, not just a convention. - Confusing
nullwith "field doesn't exist." A non-nullable (!) field that a resolver returnsnullfor is a server error, not a valid empty result — GraphQL engines reject the entire response subtree in that case. - Writing a resolver per field without considering the whole request. Section 4's naive per-field resolution is exactly what causes N+1 query storms — Part 2 covers the fix.
- Putting side effects in a Query resolver.
Queryfields are meant to be read-only, mirroring REST's GET-is-safe convention — a "query" that secretly writes breaks client assumptions about retries and caching.
📋 Quick Reference
ONE endpoint (POST /graphql), client's QUERY selects the exact fields
Schema (SDL) -- type Order { field: Type! }, enforced at request time
Query -- reads, mirrors GET
Mutation -- writes, mirrors POST/PUT/DELETE, can select result fields in the same request
Resolver -- one function per field, given (parent, args, context)
! suffix -- non-nullable; a null there from a resolver is a SERVER ERRORPart 1 covered the schema, queries/mutations, and the resolver-per-field execution model — including flagging that independent per-field resolvers are the root cause of this part's first topic. This part assumes Part 1.
1The N+1 Problem
Consider a query listing orders, each with its customer's name:
query { orders(customerId: "c1") { id customer { name } } }With Part 1's naive resolver model, if orders returns 50 rows, the engine calls Order.customer's resolver separately, once per order — 50 individual findById calls, plus the 1 call that fetched the orders list: N+1 queries for a list of N items, even though every customer lookup could have been one batched query:
// NAIVE — fires one DB query per order, 50 orders = 50 extra round trips
Order: {
customer: (parent) => ctx.db.customers.findById(parent.customerId),
}This is the GraphQL-specific flavor of the same N+1 problem covered generically in databases/indexing-storage/05-n_plus_1_query_problem — GraphQL doesn't cause it any more than an ORM does, but GraphQL's per-field resolver model makes it the default behavior unless explicitly fixed, which is why it comes up so often in GraphQL-specific discussions.
2DataLoader: Batching and Caching Within One Request
The standard fix is DataLoader — a per-request utility that collects every individual load call issued during one tick of the event loop, then fires one batched query for all of them:
import DataLoader from "dataloader";
function createCustomerLoader(db: Db) {
return new DataLoader<string, Customer>(async (customerIds) => {
// called ONCE per request-tick with ALL the ids collected so far, e.g. ["c1","c2","c3",...]
const rows = await db.customers.findByIds(customerIds); // ONE query, WHERE id IN (...)
const byId = new Map(rows.map((r) => [r.id, r]));
return customerIds.map((id) => byId.get(id)!); // MUST return results in the SAME order as customerIds
});
}
// resolver: looks identical to the naive version...
Order: {
customer: (parent, _args, ctx) => ctx.loaders.customer.load(parent.customerId), // ...but .load() is batched under the hood
}Even though Order.customer's resolver still looks like it's called once per order, DataLoader.load() doesn't hit the database immediately — it queues the id and returns a Promise, and on the next microtask tick, every queued id fires together in one findByIds call. This turns the 50 individual lookups from section 1 into exactly 2 queries total (orders + one batched customer lookup), without changing the shape of the resolver code at all. DataLoader also caches within a single request by default, so requesting the same id twice in one query never re-fetches it.
3Over-Fetching and Under-Fetching, Solved by Construction
- Over-fetching: a REST endpoint returning a fixed shape that includes fields the client doesn't need (e.g.
GET /orders/482always including a largeitemsarray even when the client only wantedstatus) — GraphQL's client-specified field selection (Part 1, section 1) eliminates this by construction, since the client only ever gets what it asked for. - Under-fetching: a REST client needing data from multiple endpoints to render one view (
GET /orders/482thenGET /customers/7to get the customer's name) — GraphQL's resolver graph (Part 1, section 4) resolves this in one request, one round trip, regardless of how many "REST endpoints" worth of data it touches.
This is the core pitch for GraphQL over REST for client-driven UIs with varied data needs (e.g. a mobile app and a web dashboard wanting different subsets of the same underlying data) — each client sends its own query shaped exactly to what it renders, without the server needing bespoke per-client endpoints or a REST ?fields= query-param workaround.
4Subscriptions: a Third Root Type
Beyond Query and Mutation, GraphQL defines Subscription for long-lived, server-push updates over a persistent connection (typically WebSockets):
type Subscription {
orderStatusChanged(orderId: ID!): Order!
}subscribe: () => pubsub.asyncIterator(`ORDER_STATUS_CHANGED_${orderId}`),A client subscribes once and receives a new payload every time the server publishes an update matching that subscription — conceptually similar to networking-web/browser-security/04-websockets's persistent-connection model, with GraphQL's type system and field-selection layered on top of the transport.
5Schema Evolution: Deprecation Instead of URL Versioning
REST versions by URL path (backend/api-design/05-api_versioning's /v1/orders -> /v2/orders). GraphQL, with its single endpoint, versions fields, not the API as a whole:
type Order {
total: Int! @deprecated(reason: "Use totalCents instead — this field will be removed on 2026-01-01.")
totalCents: Int!
}A deprecated field keeps working for existing clients (their queries still select total and still get a value) while tooling (schema introspection, IDE autocomplete) surfaces the deprecation warning to anyone writing a new query against it. This avoids REST's all-or-nothing "the whole API moved to /v2" — individual fields evolve independently, and a client's query only breaks if it explicitly selects a field that's been fully removed after its deprecation window closes.
⚠️ Common Pitfalls
- Shipping a resolver-per-field design without DataLoader. Section 1's N+1 is the default outcome, not an edge case — any list-of-objects-with-a-nested-relation query hits it immediately at real data volumes.
- A DataLoader batch function returning results out of order.
DataLoadermatches results back to callers positionally — the batch function's return array MUST be the same length and same order as the ids it was called with, or wrong data gets attached to wrong callers. - Treating GraphQL as automatically faster than REST. It solves over/under-fetching at the network-request level; it does nothing by itself about the underlying database work — an unbatched N+1 resolver graph can be slower than a well-designed REST endpoint doing one JOIN.
- Removing a field instead of deprecating it first. Breaks every client still selecting it with no warning — deprecation exists specifically to give clients a migration window.
- Forgetting
Subscriptionneeds its own transport concerns. Unlike Query/Mutation over plain HTTP POST, Subscriptions need a persistent connection (WebSocket) and its own auth/origin-validation story (networking-web/browser-security/04-websockets).
📋 Quick Reference
N+1 -- naive per-field resolver = 1 query per parent row in a list
DataLoader.load() -- queues + batches all loads in one tick into ONE query, caches per-request
Over-fetching -- fixed REST shape includes unwanted fields -- solved by client field selection
Under-fetching -- REST needs multiple endpoints for one view -- solved by resolver graph, one request
Subscription -- 3rd root type, server-push over a persistent connection (WebSocket)
Schema evolution -- @deprecated on fields, NOT URL versioning -- fields evolve independently🧠 Key takeaways
- One endpoint, a typed schema; clients select fields; queries read, mutations write.
- Resolvers run per field; nested lists cause N+1 unless batched.
- DataLoader batches and caches per request: create loaders per request.
- Evolve with @deprecated instead of versions; protect with depth/complexity limits and persisted queries.
🎤 Interview Q&A
Q1What problems does GraphQL solve?core▾
Clients request exactly the fields they need in one query, avoiding over-fetching and multiple round trips; a strongly typed schema enables tooling and introspection; and frontends can evolve without new endpoints.
- When is REST better? Simple CRUD, heavy HTTP caching needs, file transfer, or public APIs with simple usage.
Q2How do you solve the N+1 problem in GraphQL?core▾
Use DataLoader to batch and cache loads per request: resolvers request items by key, and DataLoader issues one batched query per tick (WHERE id IN (…)).
- Why per-request cache? Avoids stale or cross-user data while deduplicating within a request.
Q3How do you protect a GraphQL API from expensive queries?senior▾
Limit query depth and complexity (cost analysis), paginate lists, set timeouts, rate-limit by cost, and use persisted queries (allowlisted query hashes) for first-party clients.
- What are persisted queries? Clients send a hash of a pre-registered query instead of arbitrary text.
Q4How is caching different with GraphQL?core▾
Everything goes through one POST endpoint, so HTTP/CDN caching doesn't work out of the box. Use client-side normalized caches (Apollo, urql), persisted queries over GET for CDN caching, and server-side resolver/data caches.
- What does normalized caching mean? Storing objects by type and ID so updates reflect everywhere.
Q5How do you handle errors and versioning in GraphQL?core▾
Errors come in an errors array (often with HTTP 200); model expected business errors as part of the schema (union result types). Versioning is avoided by evolving the schema: add fields, deprecate old ones with @deprecated.
- What's federation? Composing a single graph from multiple services' subgraphs (Apollo Federation).
🛠️ Practice exercises
Each exercise is a file in this topic folder. Code exercises are self-checking: run them and make the checks pass.
✅ Check yourself
Answer the questions — feedback is instant.