GraphQL
One endpoint, client-specified queries: solving over/under-fetching, and paying for it with resolver and caching complexity.
The problem GraphQL was built to solve
REST returns fixed-shape responses from fixed endpoints, and that rigidity causes two everyday frustrations. Over-fetching is when an endpoint hands back more data than the client needs — a mobile screen that shows only a user's name still downloads their full profile, addresses, and settings because that is what GET /users/42 returns. Under-fetching is the opposite: one endpoint does not have everything the screen needs, so the client must make several round trips.
Picture a profile screen that needs a user's name, their three most recent orders, and the first product image in each order. With REST that is often three or more requests: one for the user, one for their orders, then one per order for the product. The client waits on a waterfall of calls, and each call brings back fields it will throw away.
GraphQL is a query language and runtime for APIs, created at Facebook, that lets the client specify exactly which fields it wants, across related objects, in a single request. Instead of the server dictating response shapes, the client asks for the precise tree of data it needs and gets back that exact shape — no more, no less, in one round trip.
The schema, types, and a query
A GraphQL API is defined by a schema: a strongly-typed description of every object the API exposes and how objects relate. The client can ask for any subtree of that graph. The schema is the contract — tooling reads it to validate queries before they ever run.
type User {
id: ID!
name: String!
orders: [Order!]!
}
type Order {
id: ID!
total: Float!
product: Product!
}
type Product {
id: ID!
title: String!
imageUrl: String
}
type Query {
user(id: ID!): User
}
The ! marks a field as non-null (guaranteed present); [Order!]! means a non-null list of non-null orders. Query is the special entry-point type — its fields are the queries a client may start from. Now the client sends one request describing exactly the tree it wants:
query {
user(id: "42") {
name
orders {
total
product { title imageUrl }
}
}
}
The response mirrors the query shape one-to-one: a user object with name and orders, each order with total and a nested product. The three-request REST waterfall above collapses into this single, precisely-shaped call — no over-fetching (only the named fields come back) and no under-fetching (the whole tree arrives at once).
Resolvers: how a query becomes data
The lead-in idea: the schema says what fields exist, and a resolver is the function that says how to fetch each one. Every field can have a resolver; the GraphQL runtime walks the query tree and calls the right resolver for each field, passing the parent object down as it goes.
The flow: the runtime first calls the user resolver with id 42, which returns a user object. It then calls the orders resolver, handing it that user as the parent, to fetch the list. For each order it calls the product resolver with that order as parent. The runtime stitches the returned values into the exact tree the client asked for. This is powerful because each resolver is independent — the orders resolver might read one database, product another service — and the client never sees the seams. But that independence hides a performance trap, which the next section is about.
The N+1 problem and DataLoader batching
Because each field resolves independently, a naive resolver setup fires one database query per object instead of one query per type. This is the N+1 problem: fetching a user and their N orders' products costs 1 query for the user, 1 for the orders, then N separate queries — one product lookup per order. With 50 orders that is 52 round trips for what should be a handful.
The standard fix is DataLoader: a small per-request utility that batches and caches resolver fetches. Instead of each product resolver hitting the database immediately, it registers the id it needs with a loader. The loader waits until all resolvers in that tick have registered their ids, then makes one query for the whole batch (SELECT * FROM products WHERE id IN (...)) and hands each resolver its result.
Two behaviors matter: batching collapses N individual lookups into one query per tick, and caching within a single request means asking for the same id twice hits the loader's cache, not the database. N+1 becomes 1+1. The rule of thumb: any resolver that fetches by id across a list needs a DataLoader, or your one-request-looks-elegant query quietly becomes dozens of database hits.
Queries, mutations, and subscriptions
GraphQL defines three operation types, each a different intent, and separating them keeps read, write, and live-update paths clear. All three are declared as fields on three special root types in the schema.
- Query — a read. It fetches data and, by convention, changes nothing. Multiple fields in one query may run in parallel because they do not interfere.
- Mutation — a write. It creates, updates, or deletes, and it can return the resulting object so the client refreshes its view in the same round trip. Top-level mutation fields run in series (one after another) so their order is predictable.
- Subscription — a live stream. The client subscribes to an event (usually over a WebSocket) and the server pushes a new payload whenever that event fires — a new chat message, a price change, an order status update.
mutation {
placeOrder(productId: "99", quantity: 2) {
id
total
}
}
subscription {
orderStatusChanged(orderId: "7") {
status
}
}
The placeOrder mutation performs the write and returns the new order's id and total in one call, so the client needs no follow-up read. The orderStatusChanged subscription opens a long-lived channel; every time order 7's status changes, the server pushes the new status down it. Rule of thumb: reads are queries, writes are mutations, and anything that must arrive without the client asking again is a subscription.
Caching: harder than REST
REST gets HTTP caching almost for free, and GraphQL gives that up — a trade you must plan for. In REST, a GET /products/99 is a cacheable URL: a CDN or browser cache can store the response keyed by that URL, and GET is safe to cache by default. GraphQL breaks this because every query is a POST to a single endpoint (usually /graphql), so from the network's point of view every request looks identical (same URL, same method) yet returns different data based on the body.
| Layer | REST | GraphQL |
|---|---|---|
| HTTP/CDN cache key | The URL (/products/99) | Useless — one URL for everything, POST body varies |
| Cache granularity | Whole response per endpoint | Must cache per object, in the client |
| Where caching lives | Network layer (CDN, browser) | Mostly the client library |
| Ease | Nearly free | Requires deliberate design |
GraphQL pushes caching into the client library instead. Clients like Apollo and Relay maintain a normalized cache: they break responses into individual objects keyed by type and id (for example Product:99), store each once, and reuse it across queries. When two different queries both mention product 99, the second reads from the client cache. This is more capable than URL caching in some ways (it dedupes at the object level) but it is real work you now own, whereas REST handed it to you.
Persisted queries
Two problems follow from clients sending full query text on every request: the query string can be large (wasting bandwidth on every call), and an open GraphQL endpoint lets a client submit any query, including expensive or malicious ones. Persisted queries address both.
A persisted query is a query the server has stored ahead of time under a short id (often a hash of the query text). At runtime the client sends only that id plus variables, not the whole query string. The server looks up the registered query, runs it, and returns the result.
The payoffs: requests shrink to an id and variables (less bandwidth, especially on mobile), and — if the server only accepts pre-registered ids — the API is effectively locked to a known set of safe queries, closing the door on arbitrary expensive requests. The cost is a registration step in your build and deploy pipeline. Rule of thumb: use persisted queries when bandwidth matters or when you want to whitelist exactly which queries production will run.
When GraphQL fits — and when REST or gRPC is better
GraphQL is not a default; it earns its complexity in specific situations. The lead-in rule: choose GraphQL when the shape of data clients need varies a lot and comes from many sources; choose REST or gRPC when it does not.
Where GraphQL fits well:
- Aggregating many backends. When one screen's data spans several services or databases, GraphQL's resolvers can stitch them into one graph so the client makes a single call — a natural fit for a gateway in front of microservices.
- Diverse clients with different needs. A web app, an iOS app, and a smart-watch app each want a different slice of the same data. GraphQL lets each ask for exactly its slice without the backend shipping a bespoke endpoint per client.
- Fast-moving frontends. Product teams can request new field combinations without waiting for a backend to add an endpoint, as long as the fields already exist in the schema.
Where REST or gRPC is the better tool:
- Simple, stable CRUD. If clients all want the same fixed shape, REST's cacheable endpoints are simpler and you skip the whole resolver/N+1/caching burden.
- Internal service-to-service, high volume. gRPC's binary payloads, strict contract, and streaming beat GraphQL on the internal mesh (see the gRPC day).
- Heavy reliance on HTTP/CDN caching. REST's URL-based caching is nearly free; GraphQL makes you rebuild caching in the client.
- Untrusted public clients. A wide-open GraphQL endpoint invites expensive nested queries; you must add depth limits, cost analysis, or persisted queries to make it safe, whereas a REST endpoint's cost is bounded by design.
The honest summary: GraphQL trades server-side simplicity and free caching for client-side flexibility and one-request efficiency. Take that trade when flexibility across many backends and clients is the real problem; skip it when a fixed shape and cheap caching serve you fine.
Key takeaways
- REST's fixed endpoints cause over-fetching (too many fields) and under-fetching (too many round trips); GraphQL lets the client specify the exact field tree it wants in one request.
- A GraphQL API is a strongly-typed schema of objects and relations; the client asks for any subtree and the response mirrors the query shape exactly.
- Resolvers are per-field functions the runtime calls while walking the query tree, passing each parent object down — powerful because each field can come from a different source.
- Independent resolvers cause the N+1 problem; DataLoader fixes it by batching per-tick fetches into one query and caching by id within a request.
- Queries read, mutations write (and can return the result), subscriptions push live updates over a long-lived channel.
- GraphQL gives up REST's free URL/CDN caching — every query is a POST to one endpoint — and pushes caching into a normalized client cache keyed by type+id.
- Persisted queries send a stored query id instead of full text, cutting bandwidth and letting you whitelist safe queries.
- Choose GraphQL to aggregate many backends for diverse clients; choose REST for simple cacheable CRUD and gRPC for internal high-volume traffic.
Checklist
- [ ] I can explain over-fetching and under-fetching with a concrete example.
- [ ] I can read a small GraphQL schema and write a query that fetches a nested tree.
- [ ] I can describe what a resolver does and how the runtime walks the query tree.
- [ ] I can explain the N+1 problem and how DataLoader's batching and caching solve it.
- [ ] I know the difference between queries, mutations, and subscriptions and when to use each.
- [ ] I can explain why HTTP/CDN caching does not work for GraphQL and what a normalized client cache does instead.
- [ ] I understand what persisted queries are and the two problems they solve.
- [ ] I can decide when GraphQL fits versus when REST or gRPC is the better choice.