The choice between GraphQL and REST is one of the most important architectural decisions in modern API design. This guide helps you understand when to use each approach.
Understanding REST
REST (Representational State Transfer) has been the dominant API architecture for over a decade.
REST Principles
- Stateless: Each request contains all necessary information
- Resource-based: URLs represent resources
- HTTP methods: Use standard HTTP verbs (GET, POST, PUT, DELETE)
- Uniform interface: Consistent interaction patterns
REST Example
// User API endpoints
GET / api / users; // Get all users
GET / api / users / 123; // Get specific user
POST / api / users; // Create user
PUT / api / users / 123; // Update user
DELETE / api / users / 123; // Delete user
// Related resources
GET / api / users / 123 / posts; // Get user's posts
GET / api / posts / 456 / comments; // Get post comments
Express.js REST implementation:
const express = require("express");
const app = express();
// User routes
app.get("/api/users", async (req, res) => {
const page = req.query.page || 1;
const limit = req.query.limit || 10;
const users = await User.find()
.skip((page - 1) * limit)
.limit(limit)
.select("-password");
res.json({
data: users,
pagination: {
page,
limit,
total: await User.countDocuments(),
},
});
});
app.get("/api/users/:id", async (req, res) => {
try {
const user = await User.findById(req.params.id).select("-password");
if (!user) {
return res.status(404).json({ error: "User not found" });
}
res.json(user);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
app.post("/api/users", async (req, res) => {
try {
const user = new User(req.body);
await user.save();
res.status(201).json(user);
} catch (error) {
res.status(400).json({ error: error.message });
}
});
Understanding GraphQL
GraphQL provides a more flexible approach to API design with a single endpoint.
GraphQL Principles
- Single endpoint: All operations through
/graphql
- Strongly typed: Schema defines exact data structure
- Query language: Clients specify exactly what data they need
- Introspective: Schema is queryable
GraphQL Example
Schema definition:
type User {
id: ID!
name: String!
email: String!
posts: [Post!]!
createdAt: String!
}
type Post {
id: ID!
title: String!
content: String!
author: User!
comments: [Comment!]!
publishedAt: String
}
type Comment {
id: ID!
content: String!
author: User!
post: Post!
createdAt: String!
}
type Query {
user(id: ID!): User
users(limit: Int, offset: Int): [User!]!
post(id: ID!): Post
posts(authorId: ID, limit: Int): [Post!]!
}
type Mutation {
createUser(input: CreateUserInput!): User!
updateUser(id: ID!, input: UpdateUserInput!): User!
deleteUser(id: ID!): Boolean!
createPost(input: CreatePostInput!): Post!
}
input CreateUserInput {
name: String!
email: String!
password: String!
}
input UpdateUserInput {
name: String
email: String
}
input CreatePostInput {
title: String!
content: String!
authorId: ID!
}
Resolver implementation:
const {
GraphQLSchema,
GraphQLObjectType,
GraphQLString,
GraphQLID,
GraphQLList,
} = require("graphql");
const resolvers = {
Query: {
user: async (parent, { id }) => {
return await User.findById(id);
},
users: async (parent, { limit = 10, offset = 0 }) => {
return await User.find().skip(offset).limit(limit);
},
post: async (parent, { id }) => {
return await Post.findById(id);
},
posts: async (parent, { authorId, limit = 10 }) => {
const filter = authorId ? { authorId } : {};
return await Post.find(filter).limit(limit);
},
},
Mutation: {
createUser: async (parent, { input }) => {
const user = new User(input);
return await user.save();
},
updateUser: async (parent, { id, input }) => {
return await User.findByIdAndUpdate(id, input, { new: true });
},
deleteUser: async (parent, { id }) => {
await User.findByIdAndDelete(id);
return true;
},
createPost: async (parent, { input }) => {
const post = new Post(input);
return await post.save();
},
},
User: {
posts: async (user) => {
return await Post.find({ authorId: user.id });
},
},
Post: {
author: async (post) => {
return await User.findById(post.authorId);
},
comments: async (post) => {
return await Comment.find({ postId: post.id });
},
},
Comment: {
author: async (comment) => {
return await User.findById(comment.authorId);
},
post: async (comment) => {
return await Post.findById(comment.postId);
},
},
};
Client queries:
# Get user with posts
query GetUserWithPosts($userId: ID!) {
user(id: $userId) {
id
name
email
posts {
id
title
publishedAt
comments {
id
content
author {
name
}
}
}
}
}
# Get minimal user list
query GetUserList {
users(limit: 20) {
id
name
email
}
}
# Create new post
mutation CreatePost($input: CreatePostInput!) {
createPost(input: $input) {
id
title
author {
name
}
}
}
Feature Comparison
Data Fetching
REST:
// Multiple requests needed
const user = await fetch("/api/users/123");
const posts = await fetch("/api/users/123/posts");
const comments = await fetch("/api/posts/456/comments");
// Over-fetching - gets all user fields
const userData = await user.json();
GraphQL:
// Single request with exact data needed
const query = `
query {
user(id: "123") {
name
email
posts {
title
comments {
content
}
}
}
}
`;
const response = await fetch("/graphql", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ query }),
});
Caching
REST:
// HTTP caching works naturally
app.get("/api/users/:id", (req, res) => {
res.set("Cache-Control", "public, max-age=300");
// ... return user data
});
// Client-side caching
const cache = new Map();
async function getUser(id) {
if (cache.has(`user:${id}`)) {
return cache.get(`user:${id}`);
}
const user = await fetch(`/api/users/${id}`).then((r) => r.json());
cache.set(`user:${id}`, user);
return user;
}
GraphQL:
// More complex caching
const DataLoader = require("dataloader");
const userLoader = new DataLoader(async (ids) => {
const users = await User.find({ _id: { $in: ids } });
return ids.map((id) => users.find((user) => user.id === id));
});
// Resolver with DataLoader
const resolvers = {
Post: {
author: async (post) => {
return await userLoader.load(post.authorId);
},
},
};
// Client-side normalized caching (Apollo Client)
const client = new ApolloClient({
cache: new InMemoryCache({
typePolicies: {
User: {
fields: {
posts: {
merge(existing = [], incoming) {
return [...existing, ...incoming];
},
},
},
},
},
}),
});
Error Handling
REST:
app.get("/api/users/:id", async (req, res) => {
try {
const user = await User.findById(req.params.id);
if (!user) {
return res.status(404).json({
error: "User not found",
code: "USER_NOT_FOUND",
});
}
res.json(user);
} catch (error) {
res.status(500).json({
error: "Internal server error",
code: "INTERNAL_ERROR",
});
}
});
GraphQL:
const resolvers = {
Query: {
user: async (parent, { id }) => {
try {
const user = await User.findById(id);
if (!user) {
throw new Error('User not found');
}
return user;
} catch (error) {
throw new Error(`Failed to fetch user: ${error.message}`);
}
}
}
};
// GraphQL response with partial data and errors
{
"data": {
"user": {
"name": "John Doe",
"posts": null
}
},
"errors": [
{
"message": "Failed to fetch posts",
"path": ["user", "posts"],
"extensions": {
"code": "DATABASE_ERROR"
}
}
]
}
When to Choose REST
REST is ideal when:
- Simple CRUD operations: Straightforward resource manipulation
- HTTP caching important: Leveraging browser/CDN caching
- File uploads/downloads: REST handles binary data naturally
- Team familiarity: Existing REST expertise
- External integrations: Many third-party services use REST
REST Benefits:
- Mature ecosystem: Extensive tooling and libraries
- Simple caching: HTTP caching works out of the box
- Stateless: Easy to scale horizontally
- Standards compliance: Follows HTTP conventions
When to Choose GraphQL
GraphQL is ideal when:
- Complex data relationships: Nested, interconnected data
- Mobile applications: Minimizing data transfer
- Rapid iteration: Frontend teams need flexible data access
- Multiple clients: Different apps need different data shapes
- Real-time features: Subscriptions for live updates
GraphQL Benefits:
- Precise data fetching: No over/under-fetching
- Strong typing: Schema provides clear contracts
- Introspection: Self-documenting APIs
- Single endpoint: Simplified client-server communication
Hybrid Approaches
GraphQL over REST
// Wrap REST endpoints with GraphQL
const resolvers = {
Query: {
user: async (parent, { id }) => {
const response = await fetch(`/rest-api/users/${id}`);
return response.json();
},
posts: async (parent, { authorId }) => {
const response = await fetch(`/rest-api/posts?author=${authorId}`);
return response.json();
},
},
};
REST with GraphQL-like features
// Sparse fieldsets
app.get('/api/users/:id', (req, res) => {
const fields = req.query.fields?.split(',') || [];
const projection = fields.length ? fields.join(' ') : '';
const user = await User.findById(req.params.id).select(projection);
res.json(user);
});
// Include related data
app.get('/api/users/:id', (req, res) => {
const include = req.query.include?.split(',') || [];
let query = User.findById(req.params.id);
if (include.includes('posts')) {
query = query.populate('posts');
}
if (include.includes('comments')) {
query = query.populate('posts.comments');
}
const user = await query.exec();
res.json(user);
});
Performance Considerations
REST Performance
// Implement ETags for caching
app.get("/api/users/:id", async (req, res) => {
const user = await User.findById(req.params.id);
const etag = generateETag(user);
if (req.headers["if-none-match"] === etag) {
return res.status(304).end();
}
res.set("ETag", etag);
res.json(user);
});
// Rate limiting
const rateLimit = require("express-rate-limit");
const limiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // limit each IP to 100 requests per windowMs
});
app.use("/api/", limiter);
GraphQL Performance
// Query complexity analysis
const depthLimit = require("graphql-depth-limit");
const costAnalysis = require("graphql-cost-analysis");
const server = new ApolloServer({
typeDefs,
resolvers,
validationRules: [
depthLimit(10),
costAnalysis.createRateLimitRule({
maximumCost: 1000,
scalarCost: 1,
objectCost: 2,
listFactor: 10,
}),
],
});
// Query timeouts
const resolvers = {
Query: {
users: async (parent, args, context) => {
return Promise.race([
User.find().limit(args.limit),
new Promise((_, reject) =>
setTimeout(() => reject(new Error("Query timeout")), 5000)
),
]);
},
},
};
Decision Framework
Choose REST when:
- ✅ Simple CRUD operations
- ✅ HTTP caching is crucial
- ✅ File handling required
- ✅ Team prefers familiar patterns
- ✅ Integration with REST services
Choose GraphQL when:
- ✅ Complex data relationships
- ✅ Multiple client types
- ✅ Bandwidth optimization needed
- ✅ Rapid frontend development
- ✅ Real-time features required
Consider hybrid approaches when:
- 🔄 Migrating from REST to GraphQL
- 🔄 Different parts of app have different needs
- 🔄 Want GraphQL benefits with REST infrastructure
Conclusion
Both REST and GraphQL have their place in modern API architecture. REST excels in simplicity and caching, while GraphQL shines in flexibility and efficiency. The best choice depends on your specific requirements, team expertise, and application constraints.
Consider starting with REST for simpler applications and evolving to GraphQL as complexity grows, or implement a hybrid approach that leverages the strengths of both paradigms.