The direct answer: REST exposes your data as resources, each at its own URL (/posts/10, /users/1/orders), and uses HTTP itself for the semantics: methods for actions, status codes for outcomes, and headers for caching. GraphQL exposes a single endpoint backed by a typed schema, and the client sends a query describing exactly which fields it wants, across related objects, in one request. The server returns JSON shaped like the query.
Neither is newer-and-better. REST is simpler to build, cache and operate, and suits public APIs and resource-shaped data. GraphQL pays off when many different clients (web, iOS, Android, partner integrations) need different slices of richly connected data, and the cost of adding a new REST endpoint for every screen gets high. That cost moves to the server: you have to deal with query performance, cost limits and caching yourself.
REST and GraphQL compared
| REST | GraphQL | |
|---|---|---|
| Shape of the API | Many endpoints, one per resource or collection | One endpoint (usually /graphql) and a schema |
| Who decides the response shape | The server, per endpoint | The client, per query |
| Over-fetching (fields you don’t need) | Common, unless the API supports field selection | Avoided: you get only requested fields |
| Under-fetching (extra round trips) | Common for related data | One query can traverse relations |
| Contract | OpenAPI is optional, added on top | Schema and types are required, introspectable |
| HTTP caching and CDNs | Natural: GET by URL, Cache-Control, ETag |
Harder: usually POST to one URL; needs persisted queries or client caches |
| Errors | HTTP status codes (404, 409, 500) | Often HTTP 200 with an errors array; partial data is possible |
| Versioning | URL or header versions (/v2/) |
Usually none: add fields, deprecate old ones |
| Server-side risk | Mostly per-endpoint performance | N+1 resolvers, deeply nested or expensive queries |
| File uploads, streaming | Plain HTTP | Not in the core spec; needs extensions or separate endpoints |
| Real-time | Webhooks, SSE, WebSockets alongside | Subscriptions in the spec (transport chosen by the server) |
The same screen fetched both ways
Take a blog home page that lists post titles with each author’s name. With a typical REST design you’d call GET /posts, receive full post objects (including bodies you don’t display), then call GET /authors/{id} for each distinct author. With GraphQL you’d send:
query HomePage {
posts {
title
author { name }
}
}To compare honestly, I simulated both in Node 22 with an in-memory “database” of three posts (each with a 2,000-character body) and two authors, counting payload bytes and data-layer calls. The full script is plain JavaScript with no GraphQL library, so the resolver code is a hand-written stand-in for what a GraphQL server does:
// REST: GET /posts returns full post resources; client then fetches each author.
const restPosts = await db.listPosts();
const restResponse = JSON.stringify(restPosts);
const restAuthors = await Promise.all([...new Set(restPosts.map((p) => p.authorId))].map(db.getAuthor));
console.log('REST round trips:', 1 + restAuthors.length, 'payload bytes for /posts:', restResponse.length);
// GraphQL-style: posts { title author { name } } resolved field by field.
async function resolveNaive() {
const list = await db.listPosts();
return Promise.all(list.map(async (p) => ({
title: p.title,
author: { name: (await db.getAuthor(p.authorId)).name },
})));
}REST round trips: 3 payload bytes for /posts: 6154
GraphQL naive DB queries: 4 payload bytes: 154Two things happened. The client-side win is real: one request instead of three, and 154 bytes instead of more than 6 KB, because the bodies never left the server. On a phone over a slow network, fewer round trips matter more than bytes.
But look at the server: the naive resolver made four data-layer calls (one for the list, one per post for its author) for three posts. That’s the N+1 problem, and it’s the most common GraphQL performance question. A post list of 50 means 51 queries, even though only a handful of distinct authors exist. The REST version had the same problem, just on the client side, where it shows up as extra round trips instead.
Fixing N+1 with batching
The standard fix is a per-request batching loader (the DataLoader pattern). Each resolver asks for one author, but the loader collects all the ids requested in the same tick and fetches them in one query:
function createLoader(batchFn) {
let pending = null;
return (id) => {
if (!pending) {
pending = { ids: [], promise: null };
const batch = pending;
batch.promise = Promise.resolve().then(() => { pending = null; return batchFn([...new Set(batch.ids)]); });
}
const batch = pending;
batch.ids.push(id);
return batch.promise.then((rows) => rows.find((r) => r.id === id));
};
}GraphQL batched DB queries: 2
{"data":{"posts":[{"title":"Engines","author":{"name":"Ada"}},{"title":"Compilers","author":{"name":"Grace"}},{"title":"Notes","author":{"name":"Ada"}}]}}Two queries regardless of how many posts there are: one for posts, one WHERE id IN (...) for authors. The loader must be created per request, or one user’s cached results could leak into another user’s response. The response also shows GraphQL’s envelope: data sits under data, mirroring the query’s shape.
REST APIs can close the same gaps without GraphQL. Many support an include or expand parameter (/posts?include=author) and sparse fieldsets (?fields=title), as JSON:API does. If your interviewer says “REST over-fetches”, mention that this is a design choice, not a law.
Caching, errors and versioning
Caching. A REST GET /posts/10 has a stable URL, so browsers, CDNs and reverse proxies can cache it with ordinary Cache-Control and revalidate with ETag. GraphQL queries usually go as POST bodies to one URL, which shared caches don’t store. Teams work around it with persisted queries (the client sends a hash of a pre-registered query, often via GET so a CDN can cache it) and normalised client caches such as Apollo Client’s or Relay’s, which store objects by type and id. This is a genuine operational cost, so say it plainly.
Errors. REST maps failures to status codes, and every HTTP tool understands them. Many GraphQL servers return HTTP 200 even when part of the query failed, with data holding what succeeded and errors listing what didn’t, each with a path. That partial success is useful for a dashboard where one widget can fail alone, but it means monitoring that only counts 5xx responses will miss GraphQL errors.
Versioning. REST APIs often version through the URL. GraphQL schemas usually evolve in place: add new fields freely, mark old ones @deprecated, and remove them once usage drops. Because the server sees exactly which fields each client asks for, you can measure that usage, which REST can’t do field by field.
Security and cost limits
A REST endpoint has a fixed cost you can load-test. A GraphQL endpoint accepts arbitrary queries, including deeply nested ones (posts { author { posts { author { ... } } } }) that can multiply work. Production GraphQL servers enforce a maximum depth, a query cost or complexity budget, timeouts, and pagination on list fields. Many also disable schema introspection in production for public APIs, or only accept persisted queries from known clients. Authorisation has to happen per field or per object in resolvers, since there’s no URL to put a rule on.
How to choose
Choose REST for public or partner APIs where simplicity and HTTP caching matter, for resource-shaped CRUD services, for service-to-service calls (where gRPC is also worth naming), and for file transfer. Choose GraphQL when one backend serves several clients with different data needs, screens combine many related entities, and front-end teams would otherwise wait on new endpoints. A common shape is both: internal REST or gRPC services behind a GraphQL gateway or backend-for-frontend that composes them for the UI.
Practice question: design the API for a feed
Your mobile app’s home feed shows 20 posts, each with author name, avatar, like count and the first two comments. Product wants to add fields often. Would you use REST or GraphQL, and what would you watch out for?
Answer, with the trade-offs
GraphQL is a reasonable fit: one query fetches the feed with nested authors and comments, the client takes only what it renders, and new fields don’t need a new endpoint or app release coordination. The risks are on the server. Authors and comment lookups are classic N+1, so batch them with per-request loaders. Paginate posts and comments (cursor-based, with a hard maximum page size), and set depth and cost limits so a client can’t request 20 posts with 1,000 comments each. Plan caching explicitly: persisted queries for the feed and a normalised client cache, since a CDN won’t cache POSTs. A good answer also names the REST alternative: a purpose-built GET /feed endpoint returning exactly that shape is simpler and CDN-cacheable, and is often the better choice if there’s only one client and the shape is stable.