GraphQL vs REST: Choosing the Right API Architecture
Compare GraphQL and REST APIs to make informed decisions for your project.

The GraphQL vs REST debate has been running since 2015 and it isn’t going away — because neither side is universally right. Both architectures solve real problems. The question is which problems you have. This guide maps the genuine trade-offs so you can make the call with your eyes open.
How REST Works
REST (Representational State Transfer) centres on resources — things your system knows about. Each resource has a URL; HTTP methods define what you do to it.
GET /api/v1/users/42 → fetch a user
GET /api/v1/users/42/posts → fetch their posts
POST /api/v1/posts → create a post
PATCH /api/v1/posts/99 → update a post
DELETE /api/v1/posts/99 → delete a post
A typical response:
GET /api/v1/users/42
{
"id": 42,
"name": "Alice",
"email": "alice@example.com",
"created_at": "2025-01-12T10:00:00Z",
"plan": "pro",
"avatar_url": "https://cdn.example.com/avatars/42.webp"
}
The shape of the response is fixed by the server. The client takes all of it, whether it needs it or not.
How GraphQL Works
GraphQL exposes a single endpoint. The client sends a typed query describing exactly what data it needs, and the server returns exactly that — no more.
query GetUserProfile {
user(id: 42) {
name
email
recentPosts(limit: 3) {
title
publishedAt
}
}
}
Response:
{
"data": {
"user": {
"name": "Alice",
"email": "alice@example.com",
"recentPosts": [
{ "title": "Hello World", "publishedAt": "2026-01-01" },
{ "title": "Second Post", "publishedAt": "2026-01-15" }
]
}
}
}
The client asked for name, email, and three posts. That’s exactly what came back. No plan, no avatar_url, no extra round-trips.
The Over-Fetching and Under-Fetching Problem
These two failure modes are why GraphQL was built.
Over-fetching — REST returns the full resource. A mobile app rendering just a user’s name and avatar gets every field anyway, burning bandwidth.
Under-fetching — displaying a user profile page in REST often needs multiple sequential requests:
GET /users/42 → get user
GET /users/42/posts → get their posts
GET /posts/1/comments → get comments on first post
Three round trips, three waterfalls. GraphQL collapses this into one query:
query DashboardData {
user(id: 42) {
name
avatarUrl
posts(limit: 5) {
title
publishedAt
commentCount
}
}
}
One request. One response. The server fans out to the database; the client waits once.
The N+1 Problem in GraphQL
GraphQL’s power creates its own footgun. If you’re not careful, a query that looks like one database call is actually hundreds:
query {
posts {
# 1 query for posts
author {
# 1 query per post → N+1
name
}
}
}
The fix is DataLoader (or its equivalent) — a batching and caching layer that collects all the author lookups from a single request tick and fires them as one WHERE id IN (...):
const authorLoader = new DataLoader(async (ids: number[]) => {
const users = await db.query("SELECT * FROM users WHERE id = ANY($1)", [ids]);
return ids.map((id) => users.find((u) => u.id === id));
});
REST doesn’t have this problem because the server controls the query. GraphQL gives you the flexibility and the responsibility.
Schema and Type Safety
GraphQL’s schema is a first-class contract between client and server:
type User {
id: ID!
name: String!
email: String!
posts(limit: Int = 10): [Post!]!
}
type Post {
id: ID!
title: String!
body: String!
author: User!
publishedAt: String
}
type Query {
user(id: ID!): User
posts(after: String, limit: Int): PostConnection!
}
type Mutation {
createPost(input: CreatePostInput!): Post!
deletePost(id: ID!): Boolean!
}
With graphql-codegen, this schema generates TypeScript types for both client and server automatically. A field rename breaks the build — not a runtime 500 in production.
REST achieves similar guarantees with OpenAPI, but the toolchain is less tightly integrated.
Mutations and Subscriptions
Mutations are writes:
mutation PublishPost($id: ID!) {
publishPost(id: $id) {
id
title
publishedAt
}
}
Subscriptions push real-time updates over WebSocket:
subscription OnNewComment($postId: ID!) {
commentAdded(postId: $postId) {
id
body
author {
name
}
}
}
REST handles real-time with Server-Sent Events or WebSockets — possible, but not built into the architecture.
Caching
This is REST’s clearest advantage. HTTP caching is free: ETag, Cache-Control, and CDNs work natively because URLs are stable and GET responses can be cached by a dozen layers between server and browser.
GraphQL uses POST for queries by default (though GET is possible for persisted queries). Most CDN-level caching doesn’t apply. You handle caching at the client (Apollo Client’s normalised cache) or at the field resolver level — it works, but it’s explicit work you have to do.
// Apollo Client normalised cache
const client = new ApolloClient({
cache: new InMemoryCache({
typePolicies: {
Post: {
keyFields: ["id"],
},
},
}),
});
Error Handling
REST uses HTTP status codes to signal errors. 404 means the resource doesn’t exist; 422 means validation failed; 500 means something broke on the server.
GraphQL always returns 200 OK, even for errors:
{
"data": { "user": null },
"errors": [
{
"message": "User not found",
"extensions": { "code": "NOT_FOUND", "id": 99 }
}
]
}
This trips up monitoring tools that alert on non-200 responses. You need explicit error tracking at the application layer.
Tooling Ecosystem
| Category | REST | GraphQL |
|---|---|---|
| Docs / testing | Postman, Insomnia, Swagger UI | GraphiQL, Apollo Sandbox |
| Code generation | OpenAPI Generator | graphql-codegen |
| Client libraries | axios, ky, native fetch | Apollo Client, urql, TanStack Query |
| Server frameworks | Express, Fastify, Hono | Apollo Server, Pothos, Yoga |
| Caching | HTTP-native | Apollo Client cache, persisted queries |
| Type safety | OpenAPI + validation | Schema-first, fully typed |

When to Choose REST
- Public API consumed by third parties (HTTP caching, broad familiarity).
- Simple CRUD with predictable, stable data shapes.
- Teams without GraphQL experience — the learning curve is real.
- When you need aggressive CDN caching for high-read-volume endpoints.
- Microservice-to-microservice communication.
When to Choose GraphQL
- Complex product APIs consumed by multiple front-end clients (web, iOS, Android) with different data needs.
- Mobile apps where bandwidth and battery matter.
- Rapid iteration — front-end teams can fetch what they need without waiting for a new API endpoint.
- Highly interconnected data with complex relationship traversals.
- When a typed, auto-documented schema is important to your workflow.
The Hybrid Approach
In practice, many large systems use both. REST for simple, high-volume, cacheable resources (images, public feeds, webhooks) and GraphQL for authenticated product surfaces with complex data requirements. A BFF (Backend for Frontend) pattern GraphQL layer in front of REST or gRPC microservices is common at scale.
Closing Thoughts
REST is boring and that is a genuine advantage. Its constraints are well understood; its failure modes are documented; every HTTP library speaks it. GraphQL is powerful and that power comes with real operational cost — schema governance, DataLoader wiring, client cache management. Choose the boring tool until the boring tool’s limitations are actually costing you. When they are, GraphQL is ready.
About the author
The Plake Team
Writes long-form essays on craft, software, and the slow web. Curates the Sunday digest read by 88,000+ readers.
Read moreKeep reading






