GraphQL vs REST: Making the Right Choice
You build a profile screen and it needs the user, their posts, and the comments on each post. With a typical REST API that's one request for the user, one for the posts, then one more per post for comments, and every response carries fields the screen never shows. Then the mobile team asks for a lighter version of the same screen, and you're adding ?fields= params or a new endpoint. That's usually the moment someone on the team says "should we just use GraphQL?"
This post compares GraphQL and REST on the things that actually matter when you pick one: how data gets fetched, schemas, performance and the N+1 problem, security, caching, and how to migrate if you change your mind later.
- Core architectural differences between GraphQL and REST
- How data fetching works in each paradigm
- Performance considerations and optimization strategies
- Real-world use cases and when to choose each
- Implementation examples and migration strategies
- Practical advice for running either one in production
Understanding the Fundamentals
The two solve different problems, so it helps to be clear about what each one was designed for before comparing them.
REST: Representational State Transfer
Roy Fielding described REST in his 2000 doctoral dissertation as a set of constraints for building scalable web services. In practice, a RESTful API exposes resources at URLs and you act on them with standard HTTP methods.
Resource-Based
Everything is a resource accessed via unique URLs. Each endpoint represents a specific entity or collection.
HTTP Methods
Uses GET, POST, PUT, PATCH, DELETE to perform CRUD operations on resources.
Stateless
Each request contains all information needed. Server doesn't store client context between requests.
Cacheable
Responses can be cached using standard HTTP caching mechanisms for better performance.
GraphQL: A Query Language for APIs
Facebook built GraphQL internally in 2012 and open-sourced it in 2015. It's a query language plus a runtime that executes those queries against your schema. The client writes down the fields it wants and gets back that shape, nothing more.
Single Endpoint
All queries go to one endpoint. The query itself determines what data is returned.
Precise Data Fetching
Clients specify exactly what fields they need, eliminating over-fetching and under-fetching.
Strongly Typed
Schema defines types and relationships. Provides validation and introspection capabilities.
Real-time Subscriptions
Subscriptions are part of the spec, though you still need a transport (usually WebSockets) to run them.
Data Fetching: The Core Difference
Most of the practical difference shows up in how a client gets its data. Take the profile screen from the intro: a user, their posts, and the comments on those posts.
Comparison of data fetching patterns: REST requires multiple requests while GraphQL fetches all data in one query
REST Approach: Multiple Endpoints
With a typical REST API you hit several endpoints and stitch the results together on the client:
// Fetching user data with REST: multiple requests required
async function getUserWithPosts(userId) {
// First request: Get user
const userResponse = await fetch(`/api/users/${userId}`);
const user = await userResponse.json();
// Second request: Get user's posts
const postsResponse = await fetch(`/api/users/${userId}/posts`);
const posts = await postsResponse.json();
// Third+ requests: Get comments for each post
const postsWithComments = await Promise.all(
posts.map(async (post) => {
const commentsResponse = await fetch(`/api/posts/${post.id}/comments`);
const comments = await commentsResponse.json();
return { ...post, comments };
})
);
return {
...user,
posts: postsWithComments
};
}
// Usage
const userData = await getUserWithPosts(1);
// Problem: Over-fetching, we get ALL fields from each endpoint
// Problem: Under-fetching, multiple round trips needed
GraphQL Approach: Single Query
With GraphQL the client describes the whole tree in one query and sends it in one request:
query GetUserWithPosts($userId: ID!) {
user(id: $userId) {
id
name
email
avatar
posts {
id
title
createdAt
comments {
id
body
author {
name
}
}
}
}
}
// Fetching user data with GraphQL: single request
async function getUserWithPosts(userId) {
const query = `
query GetUserWithPosts($userId: ID!) {
user(id: $userId) {
id
name
email
avatar
posts {
id
title
createdAt
comments {
id
body
author { name }
}
}
}
}
`;
const response = await fetch('/graphql', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
query,
variables: { userId }
})
});
const { data } = await response.json();
return data.user;
}
// Usage: exact data, single request
const userData = await getUserWithPosts("1");
REST is resource-oriented: the server decides which endpoints exist and what each returns. GraphQL is demand-oriented: the client decides what it needs. Moving that decision from server to client is where both the flexibility and most of the new problems come from.
Detailed Comparison
| Aspect | REST | GraphQL |
|---|---|---|
| Endpoints | Multiple endpoints for different resources | Single endpoint for all queries |
| Data Fetching | Fixed data structure per endpoint | Client specifies exact data needs |
| Over/Under-fetching | Common problem requiring workarounds | Largely avoided, since the client picks fields |
| Versioning | URL or header-based versioning | Schema evolution, deprecation |
| Caching | HTTP caching works out of the box | Requires custom implementation |
| Error Handling | HTTP status codes | Usually 200, errors in an errors array |
| Type System | Optional (OpenAPI/Swagger) | Built-in, required schema |
| Real-time | Requires WebSockets/SSE separately | Built-in subscriptions |
| Learning Curve | Lower, familiar patterns | Higher, new concepts to learn |
| Tooling | Mature, widespread | Good and improving (codegen, GraphiQL, Apollo) |
Because most GraphQL servers answer 200 OK even when a resolver fails, uptime checks and dashboards that only count 4xx/5xx responses will happily report zero errors while users see broken screens. A response can also be partial: data has some fields filled, others null, and an errors array explains why. Log and alert on the errors array, and on the client don't assume data is complete just because the request succeeded.
Schema and Type System
Every GraphQL API starts from a schema that lists every type and field a client can ask for. REST can get something similar through OpenAPI, but it's optional and often drifts out of date. Here's the same blog model both ways:
# GraphQL Schema Definition Language (SDL)
type User {
id: ID!
name: String!
email: String!
avatar: String
posts: [Post!]!
followers: [User!]!
createdAt: DateTime!
}
type Post {
id: ID!
title: String!
body: String!
author: User!
comments: [Comment!]!
likes: Int!
published: Boolean!
createdAt: DateTime!
}
type Comment {
id: ID!
body: String!
author: User!
post: Post!
createdAt: DateTime!
}
type Query {
user(id: ID!): User
users(limit: Int, offset: Int): [User!]!
post(id: ID!): Post
posts(authorId: ID, published: Boolean): [Post!]!
}
type Mutation {
createUser(input: CreateUserInput!): User!
createPost(input: CreatePostInput!): Post!
updatePost(id: ID!, input: UpdatePostInput!): Post!
deletePost(id: ID!): Boolean!
}
input CreateUserInput {
name: String!
email: String!
password: String!
}
input CreatePostInput {
title: String!
body: String!
published: Boolean = false
}
# OpenAPI 3.0 Specification (YAML)
openapi: 3.0.0
info:
title: Blog API
version: 1.0.0
paths:
/users/{id}:
get:
summary: Get user by ID
parameters:
- name: id
in: path
required: true
schema:
type: string
responses:
'200':
description: User object
content:
application/json:
schema:
$ref: '#/components/schemas/User'
/posts:
get:
summary: List posts
parameters:
- name: authorId
in: query
schema:
type: string
- name: published
in: query
schema:
type: boolean
responses:
'200':
description: Array of posts
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/Post'
components:
schemas:
User:
type: object
required: [id, name, email]
properties:
id:
type: string
name:
type: string
email:
type: string
avatar:
type: string
nullable: true
Post:
type: object
required: [id, title, body, authorId]
properties:
id:
type: string
title:
type: string
body:
type: string
authorId:
type: string
published:
type: boolean
default: false
Having the schema as the contract pays off in a few concrete ways:
- Introspection: Clients can query the schema itself to discover available types and fields
- Validation: Invalid queries are rejected before execution
- Documentation: The schema is the docs, so it can't fall out of sync with the code
- Tooling: IDE autocomplete, type generation, and more
Performance Considerations
People often say GraphQL is "faster." It can be on the network side, especially on mobile, but it moves cost onto the server and makes caching harder. Neither one is faster in general:
Performance trade-offs between REST and GraphQL across different dimensions
The N+1 Problem
This is the first performance problem most GraphQL servers hit. Resolvers run per field, per object, so a list query with a nested relation quietly turns into one database query per row:
// GraphQL Query
query {
posts { # 1 query to fetch posts
title
author { # N queries, one per post to fetch author!
name
}
}
}
// Without optimization, this generates:
// SELECT * FROM posts; -- 1 query
// SELECT * FROM users WHERE id = 1; -- N queries
// SELECT * FROM users WHERE id = 2; -- (one per post)
// SELECT * FROM users WHERE id = 3;
// ... and so on
The standard fix in Node is DataLoader, which collects all the load() calls made in one tick and turns them into a single batched query. One thing to get right: create the loaders per request (in the context function), not once at module level. A shared loader caches results across requests, so one user can end up seeing data loaded for another, and permission checks get skipped on cache hits.
import DataLoader from 'dataloader';
// Create a batch loading function
const userLoader = new DataLoader(async (userIds) => {
// Single query to fetch all users at once
const users = await User.findAll({
where: { id: userIds }
});
// Return users in the same order as requested IDs
const userMap = users.reduce((map, user) => {
map[user.id] = user;
return map;
}, {});
return userIds.map(id => userMap[id]);
});
// In your resolver
const resolvers = {
Post: {
author: (post, args, context) => {
// Uses batching: all author requests in a single query
return context.loaders.userLoader.load(post.authorId);
}
}
};
// Now the same query generates:
// SELECT * FROM posts; -- 1 query
// SELECT * FROM users WHERE id IN (1, 2, 3, ...); -- 1 query (batched!)
The same flexibility that helps your frontend also lets anyone write a deeply nested query (posts → author → posts → author...) that takes your database down. If the endpoint is reachable from the internet, put these in place:
- Query depth limiting
- Query complexity analysis
- Rate limiting per client
- Timeout mechanisms
Security Considerations
REST security mostly happens per route. With GraphQL there's one route, so the checks move down to fields and to the shape of the query itself:
REST Security
- Standard HTTP authentication
- URL-based authorization
- Rate limiting per endpoint
- Input validation on each route
- CORS configuration
GraphQL Security
- Field-level authorization
- Query complexity limits
- Depth limiting
- Introspection disabling (production)
- Persisted queries only
import { createComplexityLimitRule } from 'graphql-validation-complexity';
import depthLimit from 'graphql-depth-limit';
const server = new ApolloServer({
typeDefs,
resolvers,
validationRules: [
// Limit query depth to prevent deeply nested attacks
depthLimit(10),
// Limit query complexity based on field weights
createComplexityLimitRule(1000, {
scalarCost: 1,
objectCost: 10,
listFactor: 20,
}),
],
// Disable introspection in production
introspection: process.env.NODE_ENV !== 'production',
// Field-level authorization in context
context: ({ req }) => ({
user: authenticateUser(req),
permissions: getUserPermissions(req),
}),
});
// Field-level authorization in resolvers
const resolvers = {
User: {
email: (user, args, context) => {
// Only return email if user owns the profile or is admin
if (context.user.id === user.id || context.permissions.isAdmin) {
return user.email;
}
return null;
},
// Sensitive field with role check
salary: (user, args, context) => {
if (!context.permissions.canViewSalary) {
throw new ForbiddenError('Not authorized to view salary');
}
return user.salary;
}
}
};
When to Use Each
Neither is "better" in general. The question is which set of problems you'd rather have. This is roughly how I'd decide:
Choose GraphQL When:
- Multiple clients with different data needs: a mobile app needs far less than a web dashboard
- Complex, interconnected data: social graphs, e-commerce catalogs with lots of relations
- Rapid frontend iteration: frontend teams can use existing fields in new ways without waiting on a backend change
- Real-time features: subscriptions are part of the spec
- Mobile applications: fewer round trips and smaller payloads matter on slow networks
- Microservices aggregation: one GraphQL layer can put several services behind a single API
Choose REST When:
- Simple CRUD operations: basic resource management doesn't need a schema and resolver layer
- Caching matters a lot: HTTP caching, CDNs and browser caches just work with GET URLs
- File uploads/downloads: binary data fits HTTP naturally; GraphQL uploads need a non-standard multipart spec
- Team familiarity: almost every developer already knows REST conventions
- Public APIs: consumers can hit it with curl and nothing else
- Small team or tight budget: less infrastructure and less to secure
Quick Decision Guide
Real-World Implementation Examples
Two well-known public APIs went in opposite directions, and both had good reasons.
GitHub: Adding GraphQL
GitHub launched a GraphQL API (v4) alongside its REST API (v3), and still runs both. The reasons they gave publicly come down to:
- Fewer round trips: integrations needed several REST calls to assemble one view of a repository
- Less over-fetching: REST responses carried far more data than most clients used
- Better tooling: introspection and a typed schema for API consumers
query {
repository(owner: "facebook", name: "react") {
name
description
stargazerCount
forkCount
issues(first: 5, states: OPEN) {
nodes {
title
createdAt
author { login }
}
}
pullRequests(first: 5, states: OPEN) {
nodes {
title
additions
deletions
}
}
}
}
Stripe: Staying with REST
Stripe's REST API is often held up as an example of good API design. Why REST suits it:
- Predictable resource operations: payments map cleanly onto create, retrieve, update
- Simplicity: you can make a test charge with one curl command
- Idempotency: an
Idempotency-Keyheader on POST makes retries safe, which matters a lot when money moves - Expansion when needed: the
expand[]parameter covers many of the cases where you'd otherwise want GraphQL
// Create a payment intent
curl https://api.stripe.com/v1/payment_intents \
-u sk_test_xxx: \
-d amount=2000 \
-d currency=usd \
-d "payment_method_types[]"=card
// Response
{
"id": "pi_1abc123",
"object": "payment_intent",
"amount": 2000,
"currency": "usd",
"status": "requires_payment_method",
"client_secret": "pi_1abc123_secret_xyz"
}
Hybrid Approaches
You don't have to pick one for everything. A common setup puts GraphQL in front of the services that feed complex screens and keeps plain REST for payments, webhooks and file handling:
Hybrid architecture: GraphQL for complex data aggregation, REST for simple operations and file handling
If you're unsure, start with REST. It's simpler to build and easier to debug, and you can put a GraphQL layer on top of existing REST services later if the need shows up. Going the other way, ripping GraphQL out once clients depend on it, is much more painful.
Migration Strategies
If you do decide to move from REST to GraphQL, do it incrementally:
Start with a GraphQL Wrapper
Create a GraphQL schema that wraps your existing REST endpoints. This allows gradual migration without rewriting backend services.
Migrate High-Value Endpoints First
Identify endpoints that cause the most over-fetching or require multiple calls. These benefit most from GraphQL.
Run Both in Parallel
Maintain both APIs during migration. Deprecate REST endpoints gradually as clients migrate to GraphQL.
Monitor and Optimize
Track query patterns, performance metrics, and error rates. Optimize resolvers based on actual usage.
When I'd Pick GraphQL (and When I Wouldn't)
Both run in production at very large scale, so "will it scale" isn't really the question. What each one makes easy is different:
GraphQL Wins At
- Flexible data fetching for diverse clients
- Reducing network round trips
- Strong typing and introspection
- Real-time subscriptions
- Frontend development velocity
REST Wins At
- Simplicity and familiarity
- HTTP caching out of the box
- File uploads and downloads
- Predictable performance
- Lower learning curve
Where I'd skip GraphQL
- One backend, one frontend, same team. If the people writing the API also write the only client, they can shape REST responses to fit the screens. A schema, resolvers and DataLoaders are overhead you don't need yet.
- Public, mostly read-only, cache-heavy APIs. If most traffic is the same GET request served from a CDN, GraphQL's POST-by-default model works against you. Persisted queries over GET help, but that's extra machinery.
- Laravel or Express apps with no N+1 discipline. If nobody on the team is going to add batching and depth limits, a GraphQL endpoint can end up slower and easier to abuse than the REST API it replaced.
- Webhooks, file transfers, payment callbacks. Keep these as plain HTTP endpoints even if the rest of the app uses GraphQL.
Where it does pay off is a product with several clients that keep asking for slightly different shapes of the same data, and a team willing to own the schema properly. If you're not there yet, a well-designed REST API with sensible includes (like Stripe's expand[] or JSON:API's include) gets you surprisingly far.