Every backend team reaches the same fork in the road eventually. The API needs to serve a web client, a mobile client, and maybe a third party integration, and the question becomes whether to build REST endpoints or a GraphQL schema. Both are legitimate. Both have shipped systems at enormous scale. The argument that one of them is simply better misses the point. The real question is which set of tradeoffs fits the clients you actually have.
REST is the older style, and its shape is familiar. Resources live at URLs, HTTP verbs describe the operation, and each endpoint returns a fixed shape. A request to get a user looks like this, and the server decides exactly what comes back.
GET /api/users/42
{
"id": 42,
"name": "Ayesha Khan",
"email": "ayesha@example.com",
"avatarUrl": "https://cdn.example.com/avatars/42.png",
"createdAt": "2025-03-14T09:12:00Z",
"role": "admin",
"lastLoginAt": "2026-11-10T18:02:11Z"
}
That fixed shape is both the strength and the weakness of REST. It is easy to cache, easy to reason about, and easy to document. It is also the source of the two classic problems: over fetching and under fetching. A mobile client that only needs a user's name and avatar receives the whole record, including fields it will never render. A client that needs a user and their recent orders makes one request for the user, then another for the orders, then another for each order's items, because no single endpoint returns the nested shape it wants. The first problem wastes bandwidth. The second problem, the N plus 1 request pattern on the client side, wastes round trips, and on a mobile connection round trips are the expensive part.
GraphQL attacks exactly those two problems by letting the client describe the shape it wants. One endpoint receives a query, and the response contains exactly the fields requested, nested as deeply as the client asked. The same user request becomes this.
query {
user(id: 42) {
name
avatarUrl
orders(first: 5) {
id
total
items {
productName
quantity
}
}
}
}
The response contains the name, the avatar, and five orders with their items, and nothing else. No extra fields, no extra requests. For a client that renders a dashboard with data from five different resources, the difference is dramatic: one request replaces five, and the payload is exactly the data on screen. That is why teams with complex frontends, especially mobile teams on slow networks, often reach for GraphQL first.
The cost of that flexibility is paid on the server side, and it is worth pricing honestly. A REST endpoint has one code path per resource, and its cost is visible. A GraphQL schema has resolvers, and a naive resolver design recreates the N plus 1 problem inside the server. The query above asks for five orders and the items inside each one. If the order resolver loads orders with one query and then each order's items trigger a separate query, the server runs six queries for one request, and a deeper query can run hundreds. The standard fix is batching, usually with a DataLoader, which collects the item requests for all five orders and loads them in one query.
import DataLoader from 'dataloader';
const itemsByOrderId = new DataLoader(async (orderIds: number[]) => {
const rows = await db.query(
'SELECT * FROM order_items WHERE order_id = ANY($1)',
[orderIds],
);
return orderIds.map((id) => rows.filter((row) => row.order_id === id));
});
// Inside the order resolver:
const items = await itemsByOrderId.load(order.id);
Caching is the second server side cost, and it is the one teams underestimate most. REST responses are cacheable by URL, which means CDNs, proxies, and browsers all understand them. GraphQL sends every query to the same endpoint, usually as a POST, so the entire HTTP caching layer stops working. The workarounds exist: persisted queries that turn common operations into GET requests, response caching keyed on the query, and CDN support for GraphQL, but each one is machinery you have to build and maintain. If your API serves mostly public, cacheable data to many anonymous clients, REST gives you a CDN for free. GraphQL makes you earn it.
Versioning is where the two styles diverge philosophically. REST versions with URLs or headers: version one lives at /v1/, version two at /v2/, and old clients keep working until you retire the version. GraphQL has no versions. Fields are added to the schema and old fields are deprecated but kept, so the schema only grows. The GraphQL argument is that additive evolution avoids the maintenance burden of multiple versions. That is true when the team is disciplined about never removing fields. It becomes a different burden when the schema accumulates five years of deprecated fields that nobody dares to delete because some client somewhere still asks for them. Schema governance, field usage tracking, and a real deprecation policy are not optional in a GraphQL codebase. They are the versioning system, wearing different clothes.
Security is also different, not easier. A REST endpoint exposes one resource, so its cost is bounded and its authorization check is local. A GraphQL endpoint exposes the whole graph, so a single query can be expensive in ways that are hard to predict. A malicious or careless client can ask for a user, their orders, the items in each order, the reviews of each item, and the users who wrote those reviews, nesting deeper until the server is doing thousands of queries for one request. The defenses are query complexity analysis, depth limits, timeouts, and per field authorization, but all of them are configuration you must write. REST gets most of this for free from its shape. GraphQL requires you to build it.
There is a third option that deserves a mention, because the debate is often framed as a binary and it is not. A REST API can adopt the parts of GraphQL that solve real pain without adopting the whole system. Field selection through query parameters, sparse fieldsets like /users?fields=name,avatarUrl, and an endpoint that accepts a batch of resource requests are all cheap to add and solve most over fetching and under fetching. Conversely, a GraphQL API can sit in front of REST services internally, acting as an aggregation layer for the frontend while the services behind it stay simple. The aggregation pattern is common at scale: the public API is a GraphQL gateway, and the internal services are REST, each owned by one team.
So how do you choose? Start with the clients. If you have one web client and one mobile client with similar needs, REST is usually the simpler choice, and the simplicity compounds: simpler caching, simpler authorization, simpler debugging, simpler onboarding for new developers. If you have many clients with genuinely different data needs, a mobile app on unreliable networks, or a frontend team that is blocked waiting for backend endpoints to be built, GraphQL pays for itself quickly. The deciding question is whether the client shapes vary enough that a fixed endpoint shape is the bottleneck. If the answer is no, GraphQL is machinery in search of a problem.
A practical rule that survives contact with real projects: do not pick GraphQL because it is fashionable, and do not pick REST because it is familiar. Pick the one whose costs land where your team is strong. If your team is good at caching, schema governance, and resolver performance, GraphQL's costs are manageable and its client flexibility is real. If your team is small and the API surface is narrow, REST's simplicity is a feature, not a limitation. And whichever you choose, keep the boundary clean: one public style, documented well, with the other style used internally only where it earns its place. An API that is half REST and half GraphQL with no reason for the split is worse than either one done properly.
Top comments (0)