System Design · Synchronous Communication
GraphQL: When It Fits
GraphQL trades REST's fixed endpoints for client-specified queries — solving over- and under-fetching at the cost of caching and a new backend problem: N+1.
A REST endpoint returns a shape the server decided on ahead of time. That's fine until you have five different clients — a mobile app, a web dashboard, an admin panel, a partner integration — each wanting a slightly different slice of the same data. Someone always ends up either stuffing extra fields into the response "just in case" or bolting on a dozen query parameters to trim it back down. GraphQL flips the ownership of the response shape from the server to the client: instead of asking "what does /users/:id return?", the client says exactly which fields it wants, and gets back exactly that.
Over-fetching and under-fetching, solved by the same mechanism
These two problems are usually described separately, but GraphQL fixes them with one idea. Over-fetching is a REST endpoint handing you a full user object — bio, settings, internal flags — when your mobile screen only renders a name and an avatar. Under-fetching is the opposite: the endpoint doesn't give you enough, so you fire off two or three more requests to assemble one screen — a user, then their posts, then the comment count on each post. GraphQL collapses both into a single request where the client's query is the response shape.
# The client asks for exactly this shape — nothing more.
query ProfileHeader($userId: ID!) {
user(id: $userId) {
name
avatarUrl
posts(last: 3) {
title
publishedAt
}
}
}
# One request, one round trip, no unused fields.
# A REST equivalent is usually GET /users/:id
# followed by GET /users/:id/posts?limit=3.Nothing unused comes back, and nothing is missing — the query already describes the full shape the UI needs, including the nested posts that would otherwise be a second REST call.
The N+1 problem GraphQL hands to your backend
The convenience on the client side has to be paid for somewhere, and it's usually the resolver layer. Each field in a GraphQL schema is backed by a small function called a resolver, and resolvers are written as if they resolve one object at a time. So a query for 20 users, each resolving its own posts field, naturally turns into one query to fetch the 20 users and then 20 separate queries — one per user — to fetch each one's posts. That's the classic N+1 query problem, and it's not a bug in any one resolver; it's what happens when field-level resolution meets a relational data store.
N+1 is the default, not the exception
If you write GraphQL resolvers the naive way, N+1 isn't a rare edge case — it's what happens on the very first nested list field anyone ships. It shows up as a fine demo and a database on fire the moment real data volumes hit it.
The standard fix is batching and caching within a single request, popularized by Facebook's DataLoader. Instead of each resolver hitting the database immediately, it enqueues the key it needs ("posts for user 42") into a batch window. Once all resolvers for that tick have registered their keys, DataLoader fires one batched query — WHERE user_id IN (1, 2, 3, ...) — and hands each resolver its slice of the result. A per-request cache means the same key is never fetched twice, even if two different fields happen to need the same user.
A schema is a contract, and a machine-readable one
Every field, argument, and type in a GraphQL API is declared in a strongly-typed schema, and that schema is queryable at runtime through introspection — a client can ask the API what it supports and get back a full, structured answer. This is what makes the GraphQL tooling ecosystem feel so far ahead of typical REST tooling: editors autocomplete field names as you type a query, invalid queries fail before they're ever sent, and code generators turn the schema directly into typed client code. REST can get some of this with a hand-maintained OpenAPI spec, but with GraphQL the contract and the API are the same artifact — there's no separate document to let drift out of sync.
Caching is the trade-off nobody mentions up front
REST gets a lot of caching for free, and it's easy to forget how much. A GET /users/42 has a stable URL, so browsers, CDNs, and reverse proxies can cache it using HTTP semantics they've understood for decades. GraphQL typically exposes a single POST /graphql endpoint where the query lives in the request body — there's no URL for an intermediary to key a cache on, and POST responses aren't cached by default anywhere in the stack.
Two patterns close most of that gap. Persisted queries register each known query on the server ahead of time and let the client send a short hash instead of the full query text — that hash can be used as a stable, GET-able cache key, and it also shrinks request payloads. On the client, libraries like Apollo Client and Relay maintain a normalized cache — every object is stored once, keyed by its ID, and every query result is a set of pointers into that store. Fetch a user's name in one query and their email in another, and the cache merges both into the same normalized record instead of storing two disconnected fragments.
Not the same kind of cache
HTTP caching and a normalized client cache solve different problems — one is about avoiding network round trips for intermediaries and repeat visitors, the other is about not re-rendering or re-fetching data the app already has in memory. GraphQL leans almost entirely on the second, so teams coming from REST often need to explicitly add what they used to get for nothing.
Deciding whether it's worth it
GraphQL earns its complexity when there's a genuine data graph — many related entities — being consumed by several heterogeneous clients that each want a different view of it. A public API serving a web app, a native mobile app, and third-party integrations is the textbook case: each consumer can shape its own queries without the backend team maintaining a bespoke endpoint per client. It's a much harder sell for a small internal service with one or two well-known consumers — the resolver layer, DataLoader wiring, and cache strategy are real ongoing costs, and a couple of well-designed REST endpoints will out-simple it for years. GraphQL isn't a strictly better REST; it's a different place to put the complexity, and that trade only pays off once enough clients actually need the flexibility.
Key takeaways
- GraphQL lets the client define the response shape, fixing over-fetching and under-fetching with one mechanism instead of two.
- Field-level resolvers naturally introduce N+1 queries; DataLoader-style batching and per-request caching are the standard fix.
- The schema is a strongly-typed, introspectable contract — the same artifact powers autocomplete, validation, and codegen.
- A single POST endpoint breaks HTTP-level caching; persisted queries and normalized client caches (Apollo, Relay) close most of that gap.
- GraphQL pays off with many heterogeneous clients over a shared data graph; a simple service with one or two consumers is usually better off with REST.
