GraphQL vs REST: Choosing the Right API Style
A practical comparison of GraphQL and REST — covering over-fetching, the schema type system, the N+1 problem, HTTP caching, and a situation-based recommendation for each.

At some point you have to make a call: REST or GraphQL for the new API? It sounds like an architectural question, but it’s really a trade-off question. Both styles can deliver the data your client needs. What changes is where the complexity lives and who pays the cost when requirements shift.
REST keeps complexity on the client side. Your client makes multiple requests, stitches responses together, and deals with fields it did not need. GraphQL moves complexity to the server. Your server maintains a schema, runs resolvers per field, and figures out how to satisfy exactly what the client asked for.
Neither trade-off is free. The right one depends on what you’re building.
On this page
- The problem both API styles are trying to solve
- What REST gets right
- What GraphQL changes
- The problems GraphQL introduces
- When the differences actually matter
- Pick based on your situation
The problem both API styles are trying to solve
Every API has the same core tension: the server models the world in one shape, and the client needs a different shape for each screen.
A typical user profile page might need a name, avatar URL, and follower count. A typical REST /users/:id endpoint returns the name, email, bio, created date, preferences, last login, role, subscription plan, and a dozen other fields. The client uses three of them and discards the rest. This is over-fetching — the API returned more data than the client needed.
The reverse also happens. A feed screen might need a post title, the author’s name, and the author’s avatar. A REST /posts endpoint returns the posts but not the author details, so the client fires a second request to /users/:id for every post it renders. This is under-fetching — one request gave you enough data to realise you need more data.
Both problems are real, and both grow more expensive as traffic and client diversity increase. GraphQL was originally created at Facebook in 2012 and open-sourced in 2015 specifically because their mobile clients were suffering under this exact pressure.
What REST gets right
REST is not just an API style — it’s a set of constraints that align with how HTTP already works.
Every resource has a URL. GET requests on those URLs are cacheable by default. A CDN or reverse proxy can cache the response to GET /users/42/profile without knowing anything about your application. A Cache-Control: max-age=300 header tells every intermediary — the browser, the CDN, the edge server — that this response is valid for five minutes. The REST API setup covers this in more detail.
Status codes carry semantic meaning. 404 means the resource does not exist. 401 means the caller is unauthenticated. 409 means there is a conflict. Clients, monitoring tools, and load balancers understand these without reading your API documentation first.
curl -s https://api.example.com/users/42
# 404 Not Found — no further parsing needed
The tooling surface is also enormous. Any HTTP client, any language, and any command-line tool works with a REST API. The request format is predictable. The response format is predictable. You can debug a REST endpoint with curl in seconds.
REST makes sense when your data model maps cleanly onto resources, your client data needs roughly match what each endpoint returns, and you want the full weight of HTTP caching working without extra infrastructure. That covers most CRUD applications — admin dashboards, content APIs, mobile backends where each screen has a stable data requirement.
What GraphQL changes
The GraphQL specification defines one of its design principles as “client-specified response”: the client specifies exactly how it will consume the server’s published capabilities, and the response contains exactly what the client asked for — nothing more.
In practice, you start by defining a schema that describes every type in your system and the relationships between them:
type User {
id: ID!
name: String!
email: String!
posts: [Post!]!
}
type Post {
id: ID!
title: String!
publishedAt: String!
author: User!
}
type Query {
user(id: ID!): User
posts(limit: Int): [Post!]!
}
A client needing profile data writes a query that requests exactly the three fields it requires:
query GetProfile($userId: ID!) {
user(id: $userId) {
name
email
posts {
title
}
}
}
The response contains those fields and nothing else. No over-fetching, no second request for nested data.
The schema is also introspectable. You can query it with __schema to discover all available types and fields at runtime, which is how tools like GraphiQL and code generators work — they build from what the schema actually exposes, not from external documentation.
This matters most in two situations. First, when you have multiple clients with different data needs. A mobile app and a web dashboard can query the same API but request different shapes on the same endpoint, without the server needing to build per-client routes. Second, when you need real-time data. The spec defines subscriptions as a first-class operation type alongside queries and mutations. A client opens a persistent connection and receives data pushed from the server whenever the event fires — no polling required.
Before you continue: If you’re using TypeScript with GraphQL, tools like GraphQL Code Generator can derive TypeScript types directly from your schema, making subscription response shapes fully type-checked end-to-end.
The problems GraphQL introduces
The N+1 problem is one of the more reliable ways to introduce a performance regression when you add GraphQL without thinking through the execution model.
Consider a query that fetches a list of posts and each post’s author. A naive implementation runs one database query to get all posts, then runs a separate query for each post’s author. With 50 posts and 50 distinct authors, that’s 51 database round trips for what could be one join. The official GraphQL performance documentation describes this as an inherent risk of the field-level resolver model.
The standard fix is request batching. A tool like DataLoader collects all the author ID lookups that arrive during a single execution tick and issues one WHERE id IN (...) query instead. But that’s something you have to build, configure, and reason about — it doesn’t come for free with any GraphQL server library.
HTTP caching is the other significant cost. The official GraphQL caching documentation points out that in a URL-based API, the URL is a globally unique identifier that any intermediary can cache. In GraphQL, every operation typically goes through a single endpoint as a POST request. A CDN cannot cache a POST body by default. You can work around this with persisted queries — sending a hash of the query string rather than the full body and using GET requests for read-only queries — but that’s infrastructure a REST API doesn’t require.
Error handling also differs subtly. A REST API uses HTTP status codes to signal failure. GraphQL often returns 200 OK even when a field resolver fails — the error appears in an errors array in the response body alongside partial data. Monitoring tools that alert on non-200 responses will miss GraphQL field errors without additional setup.
Check this before picking GraphQL
- You have a plan for the N+1 problem — either DataLoader, query batching, or a data layer that handles it automatically
- Your deployment supports persistent connections for subscriptions, or you have an alternative real-time strategy
- Your team can maintain a typed schema as the product evolves
- HTTP caching is either not critical, or you have a persisted query strategy
When the differences actually matter
| Concern | REST | GraphQL |
|---|---|---|
| Data shape | Fixed per endpoint | Defined per query |
| HTTP caching | Works natively via URL and Cache-Control |
Requires workarounds for POST requests |
| Over-fetching | Common without sparse-field support | Eliminated — client requests what it needs |
| Under-fetching | Common on complex screens | Eliminated — single query for nested data |
| N+1 queries | Easy to avoid with a well-designed query layer | Possible without DataLoader or batching |
| Real-time | Requires WebSocket or SSE setup separately | Subscriptions built into the spec |
| Schema contract | Implicit — documented separately | Enforced by the type system, introspectable |
| Tooling maturity | Extremely broad | Strong in major ecosystems, growing fast |
| Error signalling | HTTP status codes | Errors in response body, status often 200 |
The row that surprises people most is HTTP caching. REST’s edge-caching story is mature and largely free — your existing CDN handles it. GraphQL’s caching story requires deliberate architecture. If you run a high-traffic read-heavy API and CDN caching is part of how you handle load, that difference is significant.
Pick based on your situation
No universal winner here. The honest recommendation is situational.
| Your situation | The better fit | Reason |
|---|---|---|
| CRUD app with stable, bounded data per screen | REST | Simpler setup, better HTTP caching, less schema overhead |
| Multiple clients (mobile, web, third-party) needing different shapes | GraphQL | Eliminates over-fetching and per-client endpoint proliferation |
| API where CDN caching is a primary performance strategy | REST | URL-based caching works without extra infrastructure |
| Complex hierarchical queries — nested data across relationships | GraphQL | Single query replaces multiple round trips |
| Small team moving fast, minimal API consumers | REST | Lower ceremony, faster to iterate |
| Real-time features that need server push, not polling | GraphQL | Subscriptions are first-class in the spec |
API consumed by curl, scripts, or third-party HTTP clients |
REST | Standard HTTP semantics, no schema knowledge required |
| Frontend team writing per-component queries with generated types | GraphQL | Schema-derived TypeScript types reduce integration bugs |
A Prisma-backed data layer pairs well with either approach — Prisma’s query API works regardless of your transport layer. Next.js route handlers also support both cleanly in the same project.
The decision usually comes down to one question: are your clients diverse and unpredictable in their data needs, or are they stable and bounded? Diverse, unpredictable clients pay back GraphQL’s complexity cost. Stable, bounded clients rarely do.