This comprehensive guide compares REST and GraphQL API paradigms, exploring their strengths, weaknesses, and optimal use cases to help you make informed architectural decisions.
Overview
What is REST?
REST (Representational State Transfer) is an architectural style that uses HTTP methods to perform CRUD operations on resources identified by URLs.
GET /api/users # Get all users
GET /api/users/123 # Get user 123
POST /api/users # Create new user
PUT /api/users/123 # Update user 123
DELETE /api/users/123 # Delete user 123
What is GraphQL?
GraphQL is a query language for APIs that allows clients to request exactly the data they need.
query {
user(id: "123") {
name
email
posts {
title
createdAt
}
}
}
Architectural Comparison
REST Architecture
graph TD
Client1[Client] -->|GET /users| API1[API Gateway]
Client2[Client] -->|GET /users/123/posts| API1
Client3[Client] -->|POST /orders| API1
API1 -->|Route| Auth[Authentication]
API1 -->|Route| Rate[Rate Limiter]
Auth --> Users[Users Service]
Auth --> Posts[Posts Service]
Auth --> Orders[Orders Service]
Users --> DB1[(User DB)]
Posts --> DB2[(Post DB)]
Orders --> DB3[(Order DB)]
Users --> Cache[(Redis Cache)]
GraphQL Architecture
graph TD
Client[Client] -->|Query| API[GraphQL API]
Client -->|Mutation| API
Client -->|Subscription| API
API -->|Parse| Parser[Parser]
Parser -->|Validate| Validator[Validator]
Validator -->|Execute| Resolver[Resolver]
Resolver -->|Fetch| Users[Users Service]
Resolver -->|Fetch| Posts[Posts Service]
Resolver -->|Fetch| Orders[Orders Service]
Users --> DB1[(User DB)]
Posts --> DB2[(Post DB)]
Orders --> DB3[(Order DB)]
Data Fetching Patterns
REST: Multiple Endpoints
// REST: Fetching nested data requires multiple requests
// Request 1: Get user
const userResponse = await fetch('/api/users/123');
const user = await userResponse.json();
// { id: 123, name: "John", email: "[email protected]" }
// Request 2: Get user's posts
const postsResponse = await fetch('/api/users/123/posts');
const posts = await postsResponse.json();
// [{ id: 1, title: "Post 1" }, { id: 2, title: "Post 2" }]
// Request 3: Get posts' comments
const commentsResponse = await fetch('/api/posts/1/comments');
const comments = await commentsResponse.json();
// [{ id: 1, text: "Comment 1" }]
// Problem: Over-fetching - we get more data than needed
// Each endpoint returns fixed structure
GraphQL: Single Request
// GraphQL: Single request with exact fields
const query = `
query GetUserData($userId: ID!) {
user(id: $userId) {
name
email
posts(first: 5) {
title
comments(first: 3) {
text
author {
name
}
}
}
}
}
`;
const response = await fetch('/graphql', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query, variables: { userId: '123' } })
});
const { data } = await response.json();
// Returns exactly what was requested - no over-fetching
REST API Implementation
Express REST API
const express = require('express');
const cors = require('cors');
const helmet = require('helmet');
const app = express();
app.use(cors());
app.use(helmet());
app.use(express.json());
// In-memory data store (replace with real DB)
const db = {
users: [
{ id: 1, name: 'John', email: '[email protected]', role: 'admin' },
{ id: 2, name: 'Jane', email: '[email protected]', role: 'user' }
],
posts: [
{ id: 1, userId: 1, title: 'First Post', content: 'Hello World' },
{ id: 2, userId: 1, title: 'Second Post', content: 'Another post' }
],
comments: [
{ id: 1, postId: 1, userId: 2, text: 'Great post!' }
]
};
// GET all resources
app.get('/api/users', (req, res) => {
const { role, limit } = req.query;
let users = [...db.users];
if (role) {
users = users.filter(u => u.role === role);
}
if (limit) {
users = users.slice(0, parseInt(limit));
}
res.json(users);
});
// GET single resource
app.get('/api/users/:id', (req, res) => {
const user = db.users.find(u => u.id === parseInt(req.params.id));
if (!user) {
return res.status(404).json({ error: 'User not found' });
}
res.json(user);
});
// GET nested resource
app.get('/api/users/:id/posts', (req, res) => {
const userId = parseInt(req.params.id);
const posts = db.posts.filter(p => p.userId === userId);
res.json(posts);
});
// GET deeply nested
app.get('/api/users/:id/posts/:postId/comments', (req, res) => {
const userId = parseInt(req.params.id);
const postId = parseInt(req.params.postId);
const comments = db.comments.filter(
c => c.postId === postId
);
res.json(comments);
});
// POST create resource
app.post('/api/users', (req, res) => {
const { name, email, role = 'user' } = req.body;
if (!name || !email) {
return res.status(400).json({ error: 'Name and email required' });
}
const newUser = {
id: db.users.length + 1,
name,
email,
role
};
db.users.push(newUser);
res.status(201).json(newUser);
});
// PUT update resource
app.put('/api/users/:id', (req, res) => {
const id = parseInt(req.params.id);
const userIndex = db.users.findIndex(u => u.id === id);
if (userIndex === -1) {
return res.status(404).json({ error: 'User not found' });
}
const updatedUser = {
...db.users[userIndex],
...req.body,
id // Prevent ID change
};
db.users[userIndex] = updatedUser;
res.json(updatedUser);
});
// DELETE resource
app.delete('/api/users/:id', (req, res) => {
const id = parseInt(req.params.id);
const userIndex = db.users.findIndex(u => u.id === id);
if (userIndex === -1) {
return res.status(404).json({ error: 'User not found' });
}
db.users.splice(userIndex, 1);
res.status(204).send();
});
app.listen(3000, () => console.log('REST API running on port 3000'));
GraphQL API Implementation
Apollo Server Setup
const { ApolloServer, gql } = require('apollo-server-express');
const express = require('express');
const { makeExecutableSchema } = require('@graphql-tools/schema');
// Type definitions
const typeDefs = gql`
type User {
id: ID!
name: String!
email: String!
role: String!
posts: [Post!]!
friends: [User!]!
}
type Post {
id: ID!
title: String!
content: String!
author: User!
comments: [Comment!]!
createdAt: String!
}
type Comment {
id: ID!
text: String!
author: User!
post: Post!
}
input CreateUserInput {
name: String!
email: String!
role: String = "user"
}
input UpdateUserInput {
name: String
email: String
role: String
}
type Query {
users(limit: Int, role: String): [User!]!
user(id: ID!): User
post(id: ID!): Post
posts(limit: Int): [Post!]!
}
type Mutation {
createUser(input: CreateUserInput!): User!
updateUser(id: ID!, input: UpdateUserInput!): User
deleteUser(id: ID!): Boolean!
createPost(title: String!, content: String!, authorId: ID!): Post!
}
type Subscription {
newPost: Post!
newComment(postId: ID!): Comment!
}
`;
// Sample data
let users = [
{ id: '1', name: 'John', email: '[email protected]', role: 'admin' },
{ id: '2', name: 'Jane', email: '[email protected]', role: 'user' }
];
let posts = [
{ id: '1', userId: '1', title: 'First Post', content: 'Hello World', createdAt: '2024-01-01' },
{ id: '2', userId: '1', title: 'Second Post', content: 'Another post', createdAt: '2024-01-02' }
];
let comments = [
{ id: '1', postId: '1', userId: '2', text: 'Great post!' }
];
// Resolvers
const resolvers = {
Query: {
users: (_, { limit, role }) => {
let result = users;
if (role) result = result.filter(u => u.role === role);
if (limit) result = result.slice(0, limit);
return result;
},
user: (_, { id }) => users.find(u => u.id === id),
posts: (_, { limit }) => limit ? posts.slice(0, limit) : posts,
post: (_, { id }) => posts.find(p => p.id === id)
},
User: {
posts: (user) => posts.filter(p => p.userId === user.id),
friends: (user) => {
// Simple example: users with same role
return users.filter(u => u.role === user.role && u.id !== user.id);
}
},
Post: {
author: (post) => users.find(u => u.id === post.userId),
comments: (post) => comments.filter(c => c.postId === post.id)
},
Comment: {
author: (comment) => users.find(u => u.id === comment.userId),
post: (comment) => posts.find(p => p.id === comment.postId)
},
Mutation: {
createUser: (_, { input }) => {
const newUser = {
id: String(users.length + 1),
...input
};
users.push(newUser);
return newUser;
},
updateUser: (_, { id, input }) => {
const index = users.findIndex(u => u.id === id);
if (index === -1) return null;
users[index] = { ...users[index], ...input };
return users[index];
},
deleteUser: (_, { id }) => {
const index = users.findIndex(u => u.id === id);
if (index === -1) return false;
users.splice(index, 1);
return true;
},
createPost: (_, { title, content, authorId }) => {
const newPost = {
id: String(posts.length + 1),
title,
content,
userId: authorId,
createdAt: new Date().toISOString()
};
posts.push(newPost);
return newPost;
}
}
};
// Create server
const schema = makeExecutableSchema({ typeDefs, resolvers });
const app = express();
const server = new ApolloServer({
schema,
context: ({ req }) => ({
// Add authentication info
user: req.user
})
});
await server.start();
server.applyMiddleware({ app });
app.listen(4000, () => {
console.log(`GraphQL server at http://localhost:4000${server.graphqlPath}`);
});
Over-fetching Problem
// REST: Get user profile - always returns all fields
// /api/users/123 might return 15 fields
// But mobile app only needs name and avatar (2 fields)
// Waste: 13 fields * millions of requests = significant bandwidth
// GraphQL: Request only what you need
const query = `
query MobileUser($id: ID!) {
user(id: $id) {
name
avatar
}
}
`;
Under-fetching Problem
// REST: Dashboard with multiple widgets
// Need 5 different API calls
const dashboardData = {
user: await fetch('/api/user/123').then(r => r.json()),
notifications: await fetch('/api/notifications').then(r => r.json()),
recentPosts: await fetch('/api/users/123/posts?limit=5').then(r => r.json()),
stats: await fetch('/api/stats').then(r => r.json()),
messages: await fetch('/api/messages?unread=true').then(r => r.json())
};
// GraphQL: Single request
const query = `
query Dashboard {
user(id: "123") {
name
avatar
}
notifications(unread: true) {
id
message
}
posts(userId: "123", limit: 5) {
title
createdAt
}
stats {
views
likes
}
messages(unread: true) {
id
preview
}
}
`;
Caching Strategies
REST: HTTP Caching
// REST: HTTP Caching with ETag
app.get('/api/users/:id', (req, res) => {
const user = getUser(req.params.id);
// Generate ETag
const etag = crypto.createHash('md5')
.update(JSON.stringify(user))
.digest('hex');
// Check If-None-Match
if (req.headers['if-none-match'] === etag) {
return res.status(304).end(); // Not Modified
}
res.set('ETag', etag);
res.set('Cache-Control', 'public, max-age=3600');
res.json(user);
});
// Redis caching
app.get('/api/users/:id', async (req, res) => {
const cacheKey = `user:${req.params.id}`;
// Check cache
const cached = await redis.get(cacheKey);
if (cached) {
return res.json(JSON.parse(cached));
}
const user = await db.users.findById(req.params.id);
// Cache for 1 hour
await redis.setex(cacheKey, 3600, JSON.stringify(user));
res.json(user);
});
GraphQL: Normalized Caching
import { InMemoryCache, ApolloClient } from '@apollo/client';
const cache = new InMemoryCache({
typePolicies: {
User: {
keyFields: ['id'],
fields: {
posts: {
merge(existing, incoming) {
return incoming;
}
}
}
},
Post: {
keyFields: ['id']
}
}
});
const client = new ApolloClient({
cache,
uri: 'http://localhost:4000/graphql'
});
// After query, data is cached and normalized
// Subsequent queries can use cached data
const { data } = await client.query({
query: GET_USER,
variables: { id: '123' },
fetchPolicy: 'cache-first' // Use cache if available
});
When to Choose REST
Best Use Cases
// 1. Simple CRUD operations
// Resources are clearly defined and map well to URLs
app.get('/api/products'); // List products
app.get('/api/products/123'); // Get product
app.post('/api/products'); // Create product
app.put('/api/products/123'); // Update product
app.delete('/api/products/123');
// 2. Public APIs with rate limiting
// Standard HTTP caching, CDN-friendly
app.get('/api/public/leaderboard', (req, res) => {
res.set('Cache-Control', 'public, max-age=60');
res.json(getLeaderboard());
});
// 3. Microservices with clear boundaries
// Each service owns its resources
// /users-service/api/users
// /orders-service/api/orders
// /inventory-service/api/products
REST Advantages
| Advantage |
Description |
| Simplicity |
Easy to understand, widely adopted |
| HTTP Caching |
Native support with ETag, Last-Modified |
| CDN Support |
Works seamlessly with content delivery networks |
| Stateless |
Each request contains all information |
| Standardized |
HTTP methods, status codes, headers |
| Tooling |
Extensive client libraries, API tools |
REST Disadvantages
| Disadvantage |
Description |
| Over-fetching |
Returns fixed data structure |
| Multiple Round-trips |
Deep nesting requires many requests |
| Versioning |
API versions in URL or headers |
| Documentation |
Endpoint documentation needs maintenance |
When to Choose GraphQL
Best Use Cases
// 1. Mobile apps with limited bandwidth
// Request exactly what UI needs
const query = `
query MobileProductList {
products {
id
name
price
image
}
}
`;
// 2. Complex data relationships
// Nested queries without multiple requests
const query = `
query ComplexDashboard {
user {
name
posts {
title
comments {
author { name }
text
}
}
}
}
`;
// 3. Rapid frontend development
// Frontend can evolve independently
// Add new fields without backend changes
GraphQL Advantages
| Advantage |
Description |
| Flexible Queries |
Client requests exactly needed data |
| Single Request |
Fetch multiple resources in one call |
| Strong Typing |
Schema defines types, self-documenting |
| Real-time |
Subscriptions for live updates |
| Schema Evolution |
Additive changes without versioning |
GraphQL Disadvantages
| Disadvantage |
Description |
| Complexity |
Steeper learning curve |
| Caching |
More complex than HTTP caching |
| File Upload |
Not natively supported |
| N+1 Problem |
Can cause excessive database queries |
| Rate Limiting |
Harder to implement per-field limits |
Decision Framework
flowchart TD
A[Start] --> B{Simple CRUD?}
B -->|Yes| C[Use REST]
B -->|No| D{Mobile/limited bandwidth?}
D -->|Yes| E[Use GraphQL]
D -->|No| F{Multiple clients?}
F -->|Yes| G[GraphQL]
F -->|No| H{Complex relationships?}
H -->|Yes| I[GraphQL]
H -->|No| J{Real-time needed?}
J -->|Yes| K[GraphQL + Subscriptions]
J -->|No| L{Rate limiting critical?}
L -->|Yes| M[REST]
L -->|No| N{Cache-heavy?}
N -->|Yes| O[REST]
N -->|No| P[Either works - consider team expertise]
Quick Decision Guide
| Scenario |
Recommendation |
| Public API |
REST |
| Internal microservice |
REST or gRPC |
| Mobile app |
GraphQL |
| Complex dashboard |
GraphQL |
| Real-time updates |
GraphQL + Subscriptions |
| Simple CRUD |
REST |
| CDN caching |
REST |
| Team unfamiliar with GraphQL |
REST |
Hybrid Approach
// Use REST for simple, cacheable resources
// Use GraphQL for complex, dynamic queries
const express = require('express');
const { ApolloServer } = require('apollo-server-express');
const app = express();
// REST endpoints for simple resources
app.get('/api/status', (req, res) => {
res.json({ status: 'ok', timestamp: Date.now() });
});
app.get('/api/public/leaderboard', cache(60), (req, res) => {
res.json(getLeaderboard());
});
// GraphQL for complex queries
const server = new ApolloServer({ typeDefs, resolvers });
await server.start();
server.applyMiddleware({ app, path: '/graphql' });
app.listen(3000);
Best Practices
REST Best Practices
// Use consistent naming
GET /api/users // Plural nouns
GET /api/users/123 // Resource ID in URL
GET /api/users/123/posts // Nested resources
// Use query parameters for filtering, sorting, pagination
GET /api/users?role=admin&sort=name&limit=10&offset=20
// Use proper status codes
200 - OK
201 - Created
204 - No Content (successful delete)
400 - Bad Request
401 - Unauthorized
403 - Forbidden
404 - Not Found
500 - Internal Server Error
// Version your API
GET /api/v1/users
GET /api/v2/users
GraphQL Best Practices
// Schema-first design
const typeDefs = gql`
type Query {
# Clear, descriptive names
activeUsers: [User!]!
}
type User {
# Always include ID
id: ID!
# Descriptive field names
createdAt: DateTime!
postCount: Int!
}
`;
// Use fragments to share fields
const UserFields = `
fragment UserFields on User {
id
name
email
}
`;
// Connection pattern for pagination
const typeDefs = gql`
type UserConnection {
edges: [UserEdge!]!
pageInfo: PageInfo!
totalCount: Int!
}
type UserEdge {
node: User!
cursor: String!
}
type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: String
endCursor: String
}
`;
// Handle N+1 with DataLoader
const DataLoader = require('dataloader');
const userLoader = new DataLoader(async (ids) => {
const users = await db.users.findByIds(ids);
return ids.map(id => users.find(u => u.id === id));
});
// In resolver
User: {
async posts(user) {
return postLoader.load(user.id);
}
}
External Resources
Related Articles
Comments