Skip to main content

GraphQL vs REST API: Complete Comparison Guide

Learn when to use GraphQL vs REST API with practical examples, performance comparisons, and real-world use cases to make the right architectural choice.

Table of Contents

Introduction

Choosing between GraphQL vs REST API is one of the most critical architectural decisions you’ll make when designing your application’s backend. Both technologies solve the problem of client-server communication, but they approach it from fundamentally different angles. Understanding their strengths, weaknesses, and ideal use cases will help you build more efficient, maintainable, and scalable applications.

REST (Representational State Transfer) has been the dominant API architecture for over two decades, powering millions of web services with its simple, stateless, resource-based approach. GraphQL, introduced by Facebook in 2015, offers a query language that allows clients to request exactly the data they need, reducing over-fetching and under-fetching issues common with REST.

In this comprehensive guide, you’ll learn the core concepts of both approaches, see practical code examples, understand performance implications, and discover when each technology shines. Whether you’re building a new API or considering migrating an existing one, this comparison will give you the insights needed to make an informed decision.


Understanding REST API

REST API is an architectural style that uses HTTP methods to perform operations on resources identified by URLs. It follows a stateless, client-server model where each request contains all the information needed to process it.

Core Principles of REST

REST APIs follow six key constraints:

  1. Stateless: Each request must contain all information needed to process it
  2. Client-Server: Clear separation between client and server concerns
  3. Uniform Interface: Consistent way to interact with resources
  4. Cacheable: Responses should be cacheable when possible
  5. Layered System: Architecture can be composed of hierarchical layers
  6. Code on Demand (optional): Servers can extend client functionality

REST API Example

Here’s a typical REST API implementation using Express.js:

// REST API endpoint for users
import express from "express";
const app = express();
app.use(express.json());
// GET /users - Fetch all users
app.get("/users", async (req, res) => {
const users = await db.users.findAll();
res.json(users);
});
// GET /users/:id - Fetch a specific user
app.get("/users/:id", async (req, res) => {
const user = await db.users.findById(req.params.id);
if (!user) {
return res.status(404).json({ error: "User not found" });
}
res.json(user);
});
// GET /users/:id/posts - Fetch user's posts
app.get("/users/:id/posts", async (req, res) => {
const posts = await db.posts.findByUserId(req.params.id);
res.json(posts);
});
// POST /users - Create a new user
app.post("/users", async (req, res) => {
const user = await db.users.create(req.body);
res.status(201).json(user);
});
// PUT /users/:id - Update a user
app.put("/users/:id", async (req, res) => {
const user = await db.users.update(req.params.id, req.body);
res.json(user);
});
// DELETE /users/:id - Delete a user
app.delete("/users/:id", async (req, res) => {
await db.users.delete(req.params.id);
res.status(204).send();
});

Making REST API Requests

Here’s how clients consume REST APIs:

// Fetching user data with REST
async function getUserWithPosts(userId: string) {
// First request: Get user
const userResponse = await fetch(`https://api.example.com/users/${userId}`);
const user = await userResponse.json();
// Second request: Get user's posts
const postsResponse = await fetch(
`https://api.example.com/users/${userId}/posts`,
);
const posts = await postsResponse.json();
// Third request: Get user's comments (if needed)
const commentsResponse = await fetch(
`https://api.example.com/users/${userId}/comments`,
);
const comments = await commentsResponse.json();
return {
...user,
posts,
comments,
};
}

REST API Strengths

Simple and Familiar: Uses standard HTTP methods (GET, POST, PUT, DELETE)
Caching: HTTP caching mechanisms work out of the box
Stateless: Easy to scale horizontally
Mature Ecosystem: Extensive tooling and documentation
Browser Support: Native browser support for HTTP requests

For a comprehensive reference on REST API design, check out our REST API Cheatsheet.


Understanding GraphQL

GraphQL is a query language and runtime for APIs that allows clients to request exactly the data they need. Instead of multiple endpoints, GraphQL uses a single endpoint and a type system to define available data.

Core Concepts of GraphQL

  1. Schema: Defines the data structure and available operations
  2. Queries: Read operations to fetch data
  3. Mutations: Write operations to modify data
  4. Subscriptions: Real-time updates using WebSockets
  5. Resolvers: Functions that fetch data for each field

GraphQL Schema Example

// GraphQL schema definition
import { gql } from "apollo-server-express";
const typeDefs = gql`
type User {
id: ID!
name: String!
email: String!
posts: [Post!]!
comments: [Comment!]!
}
type Post {
id: ID!
title: String!
content: String!
author: User!
comments: [Comment!]!
}
type Comment {
id: ID!
content: String!
author: User!
post: Post!
}
type Query {
user(id: ID!): User
users: [User!]!
post(id: ID!): Post
posts: [Post!]!
}
type Mutation {
createUser(name: String!, email: String!): User!
updateUser(id: ID!, name: String, email: String): User!
deleteUser(id: ID!): Boolean!
}
`;

GraphQL Resolvers Example

// GraphQL resolvers implementation
const resolvers = {
Query: {
user: async (parent, { id }) => {
return await db.users.findById(id);
},
users: async () => {
return await db.users.findAll();
},
},
User: {
// Resolver for nested field
posts: async (parent) => {
return await db.posts.findByUserId(parent.id);
},
comments: async (parent) => {
return await db.comments.findByUserId(parent.id);
},
},
Mutation: {
createUser: async (parent, { name, email }) => {
return await db.users.create({ name, email });
},
updateUser: async (parent, { id, name, email }) => {
return await db.users.update(id, { name, email });
},
deleteUser: async (parent, { id }) => {
await db.users.delete(id);
return true;
},
},
};

Making GraphQL Queries

Here’s how clients query GraphQL APIs:

// Fetching user data with GraphQL
const GET_USER_WITH_POSTS = gql`
query GetUser($userId: ID!) {
user(id: $userId) {
id
name
email
posts {
id
title
content
}
comments {
id
content
post {
id
title
}
}
}
}
`;
// Single request gets everything needed
async function getUserWithPosts(userId: string) {
const { data } = await client.query({
query: GET_USER_WITH_POSTS,
variables: { userId },
});
return data.user;
}

GraphQL Strengths

Precise Data Fetching: Request exactly what you need
Single Endpoint: One endpoint for all operations
Strong Typing: Type-safe schema prevents errors
Introspection: Self-documenting API with built-in schema exploration
Reduced Over-fetching: No unnecessary data transfer

For a comprehensive reference on GraphQL, check out our GraphQL Cheatsheet.


Key Differences

Understanding the fundamental differences between GraphQL and REST API will help you choose the right approach for your project.

Data Fetching Approach

REST API: Multiple endpoints, fixed response structure

// REST: Multiple requests for related data
GET /users/1 // Returns full user object
GET /users/1/posts // Returns all posts
GET /users/1/comments // Returns all comments
// Response includes everything, even if not needed
{
"id": 1,
"name": "John Doe",
"email": "john@example.com",
"phone": "123-456-7890", // Not needed, but included
"address": "...", // Not needed, but included
"bio": "...", // Not needed, but included
}

GraphQL: Single endpoint, flexible query structure

# GraphQL: Single request, specify exact fields
query {
user(id: 1) {
name
email
posts {
title
}
}
}
# Response contains only requested fields
{
"data": {
"user": {
"name": "John Doe",
"email": "john@example.com",
"posts": [
{ "title": "Post 1" }
]
}
}
}

HTTP Methods vs Operations

REST API: Uses HTTP methods to indicate operations

GET /users // Read
POST /users // Create
PUT /users/:id // Update (full)
PATCH /users/:id // Update (partial)
DELETE /users/:id // Delete

GraphQL: Uses operation types in the query

# Query (read)
query {
users {
name
}
}
# Mutation (write)
mutation {
createUser(name: "John", email: "john@example.com") {
id
name
}
}
# Subscription (real-time)
subscription {
userCreated {
id
name
}
}

Error Handling

REST API: Uses HTTP status codes

// REST error responses
app.get("/users/:id", async (req, res) => {
const user = await db.users.findById(req.params.id);
if (!user) {
return res.status(404).json({
error: "User not found",
code: "USER_NOT_FOUND",
});
}
if (!user.isActive) {
return res.status(403).json({
error: "User account is inactive",
code: "ACCOUNT_INACTIVE",
});
}
res.status(200).json(user);
});

GraphQL: Returns 200 status with errors in response

// GraphQL error responses
const resolvers = {
Query: {
user: async (parent, { id }) => {
const user = await db.users.findById(id);
if (!user) {
throw new UserInputError('User not found', {
code: 'USER_NOT_FOUND'
});
}
return user;
}
}
};
// Response structure
{
"data": {
"user": null
},
"errors": [
{
"message": "User not found",
"code": "USER_NOT_FOUND",
"path": ["user"]
}
]
}

Versioning

REST API: URL-based versioning

// REST versioning approaches
/api/v1/users
/api/v2/users
/api/users?version=2
// Header-based versioning
GET /api/users
Headers: { "API-Version": "2" }

GraphQL: Schema evolution (backward compatible)

# GraphQL: Add new fields without breaking existing queries
type User {
id: ID!
name: String!
email: String!
# New field added - existing queries still work
phone: String
avatar: String
}

Performance Comparison

Performance characteristics differ significantly between GraphQL and REST API, impacting your application’s efficiency and user experience.

Network Requests

REST API: Multiple round trips

// REST: 3 separate HTTP requests
async function getDashboardData(userId: string) {
const user = await fetch(`/api/users/${userId}`); // Request 1
const posts = await fetch(`/api/users/${userId}/posts`); // Request 2
const comments = await fetch(`/api/users/${userId}/comments`); // Request 3
return { user, posts, comments };
}
// Total: 3 HTTP requests, 3 round trips

GraphQL: Single request

# GraphQL: 1 HTTP request
query GetDashboardData($userId: ID!) {
user(id: $userId) {
name
email
posts {
title
content
}
comments {
content
}
}
}
# Total: 1 HTTP request, 1 round trip

Data Transfer Size

REST API: Over-fetching common issue

// REST response: Includes all fields
GET /api/users/1
Response: {
"id": 1,
"name": "John Doe",
"email": "john@example.com",
"phone": "123-456-7890",
"address": "123 Main St, City, State 12345",
"bio": "Long biography text...",
"avatar": "https://...",
"createdAt": "2024-01-01T00:00:00Z",
"updatedAt": "2024-12-19T00:00:00Z",
"settings": { /* ... */ },
"preferences": { /* ... */ }
}
// Size: ~2KB (but only needed name and email)

GraphQL: Fetch only needed fields

# GraphQL query: Request only name and email
query {
user(id: 1) {
name
email
}
}
# Response: Only requested fields
{
"data": {
"user": {
"name": "John Doe",
"email": "john@example.com"
}
}
}
# Size: ~100 bytes

Caching

REST API: HTTP caching works naturally

// REST: Leverages HTTP caching headers
app.get("/users/:id", async (req, res) => {
const user = await db.users.findById(req.params.id);
res.set({
"Cache-Control": "public, max-age=3600",
ETag: user.version,
});
res.json(user);
});
// Browser/CDN can cache responses automatically

GraphQL: Requires custom caching strategy

// GraphQL: Need to implement custom caching
import { InMemoryLRUCache } from "apollo-server-caching";
const server = new ApolloServer({
typeDefs,
resolvers,
cache: new InMemoryLRUCache({
maxSize: 50 * 1024 * 1024, // 50MB
ttl: 3600, // 1 hour
}),
// Field-level caching
plugins: [
{
requestDidStart() {
return {
willSendResponse(requestContext) {
// Set cache headers based on query
requestContext.response.http.headers.set(
"Cache-Control",
"public, max-age=3600",
);
},
};
},
},
],
});

N+1 Query Problem

⚠️ GraphQL Risk: N+1 queries can occur without proper handling

// GraphQL resolver without batching (N+1 problem)
const resolvers = {
Query: {
posts: async () => {
return await db.posts.findAll(); // 1 query
},
},
Post: {
author: async (post) => {
// This runs for EACH post - N queries!
return await db.users.findById(post.authorId);
},
},
};
// If you fetch 100 posts, this results in:
// 1 query for posts + 100 queries for authors = 101 queries!

Solution: Use DataLoader for batching

// GraphQL with DataLoader (solves N+1)
import DataLoader from "dataloader";
// Create a batch loader
const userLoader = new DataLoader(async (userIds) => {
const users = await db.users.findByIds(userIds);
// Return users in same order as userIds
return userIds.map((id) => users.find((u) => u.id === id));
});
const resolvers = {
Query: {
posts: async () => {
return await db.posts.findAll(); // 1 query
},
},
Post: {
author: async (post) => {
// Batched: All author queries combined into 1
return await userLoader.load(post.authorId);
},
},
};
// Now: 1 query for posts + 1 batched query for authors = 2 queries total!

When to Use REST API

REST API excels in specific scenarios where its simplicity and HTTP-native features provide clear advantages.

✅ Simple CRUD Operations

REST is perfect for straightforward create, read, update, delete operations:

// REST shines for simple CRUD
GET /api/products // List products
GET /api/products/:id // Get product
POST /api/products // Create product
PUT /api/products/:id // Update product
DELETE /api/products/:id // Delete product
// Clear, predictable, easy to understand

✅ HTTP Caching Requirements

When you need robust HTTP caching:

// REST: Leverage CDN and browser caching
app.get("/api/products", async (req, res) => {
const products = await db.products.findAll();
res.set({
"Cache-Control": "public, max-age=3600",
ETag: productsVersion,
"Last-Modified": lastModifiedDate,
});
res.json(products);
});
// CDNs can cache this automatically
// Browsers can cache this automatically
// No custom implementation needed

✅ File Uploads and Downloads

REST handles file operations naturally:

// REST: File upload/download
app.post("/api/files", upload.single("file"), (req, res) => {
const file = req.file;
// Process file...
res.json({ url: file.url });
});
app.get("/api/files/:id/download", (req, res) => {
const file = await db.files.findById(req.params.id);
res.download(file.path);
});

✅ Microservices Architecture

REST works well in microservices where each service has clear boundaries:

// Microservice 1: User service
GET /api/users/:id
// Microservice 2: Order service
GET /api/orders/:id
// Microservice 3: Payment service
POST /api/payments
// Each service is independent, REST fits naturally

✅ Public APIs

REST is ideal for public APIs consumed by various clients:

// Public REST API - easy for any client to consume
GET /api/v1/products
GET /api/v1/products?category=electronics&page=1&limit=20
// Works with:
// - Web browsers (fetch API)
// - Mobile apps (HTTP libraries)
// - cURL commands
// - Postman/Insomnia
// - Any HTTP client

When to Use GraphQL

GraphQL provides significant advantages when you need flexible data fetching and complex relationships.

✅ Mobile Applications

GraphQL reduces data transfer for mobile apps:

# Mobile app: Only fetch what's needed for small screen
query MobileUserProfile($userId: ID!) {
user(id: $userId) {
name
avatar
# Skip heavy fields like bio, full address, etc.
}
}
# Web app: Fetch more data for larger screen
query WebUserProfile($userId: ID!) {
user(id: $userId) {
name
email
avatar
bio
posts {
title
content
}
comments {
content
}
}
}

✅ Complex Data Relationships

GraphQL excels when data has many relationships:

# Fetch complex nested data in one query
query GetProjectDetails($projectId: ID!) {
project(id: $projectId) {
name
description
owner {
name
avatar
}
tasks {
id
title
assignee {
name
email
}
comments {
content
author {
name
}
}
}
milestones {
name
dueDate
tasks {
title
}
}
}
}
# Single request gets everything, no over-fetching

✅ Rapid Frontend Development

GraphQL enables faster frontend iteration:

// Frontend developer can add fields without backend changes
query GetUser {
user(id: 1) {
name
email
# Add new field - backend already supports it!
phone
# Add another field
avatar
}
}
// No need to:
// - Wait for backend to add new endpoint
// - Modify backend code
// - Deploy backend changes
// Just update the query!

✅ Multiple Client Types

When serving web, mobile, and other clients with different needs:

# Web client: Rich data
query WebDashboard {
user {
name
email
posts { title content comments { content } }
followers { name avatar }
following { name avatar }
}
}
# Mobile client: Minimal data
query MobileDashboard {
user {
name
avatar
posts { title }
}
}
# Admin client: Everything
query AdminDashboard {
user {
# All fields
}
}
# Same endpoint, different queries for each client

✅ Real-time Features

GraphQL subscriptions for real-time updates:

# GraphQL subscription for real-time chat
subscription MessageAdded($roomId: ID!) {
messageAdded(roomId: $roomId) {
id
content
author {
name
avatar
}
timestamp
}
}
# Automatically receives new messages as they're created

Migration Strategies

Migrating between REST and GraphQL requires careful planning. Here are practical strategies for both directions.

Migrating from REST to GraphQL

Strategy 1: GraphQL Layer Over REST

// Step 1: Keep existing REST API
// Step 2: Add GraphQL layer that calls REST internally
const resolvers = {
Query: {
user: async (parent, { id }) => {
// Call existing REST endpoint
const response = await fetch(`http://api.example.com/users/${id}`);
return await response.json();
},
users: async () => {
const response = await fetch("http://api.example.com/users");
return await response.json();
},
},
};
// Gradually migrate resolvers to direct database access

Strategy 2: Parallel Implementation

// Run both REST and GraphQL side by side
// Migrate clients gradually
// REST endpoints (existing)
app.get("/api/users", getUsersREST);
app.get("/api/users/:id", getUserREST);
// GraphQL endpoint (new)
app.use("/graphql", graphqlMiddleware);
// Clients can migrate at their own pace

Strategy 3: Field-by-Field Migration

# Start with simple queries, add complexity over time
# Phase 1: Basic user data
query {
user(id: 1) {
id
name
email
}
}
# Phase 2: Add relationships
query {
user(id: 1) {
id
name
email
posts {
title
}
}
}
# Phase 3: Add mutations
mutation {
updateUser(id: 1, name: "New Name") {
id
name
}
}

Migrating from GraphQL to REST

Strategy 1: Create REST Endpoints for Common Queries

// Identify most common GraphQL queries
// Create REST endpoints that match them
// Common GraphQL query:
// query { user(id: 1) { name email posts { title } } }
// Create equivalent REST endpoint:
app.get("/api/users/:id/summary", async (req, res) => {
const user = await db.users.findById(req.params.id);
const posts = await db.posts.findByUserId(req.params.id);
res.json({
name: user.name,
email: user.email,
posts: posts.map((p) => ({ title: p.title })),
});
});

Strategy 2: Gradual Deprecation

// Mark GraphQL as deprecated, provide REST alternatives
const typeDefs = gql`
type Query {
user(id: ID!): User @deprecated(reason: "Use GET /api/users/:id")
users: [User!]! @deprecated(reason: "Use GET /api/users")
}
`;
// Provide migration guide and timeline

Best Practices

Following best practices ensures your API is maintainable, performant, and developer-friendly regardless of which approach you choose.

REST API Best Practices

Use Proper HTTP Methods

// ✅ Correct HTTP method usage
GET /api/users // Read collection
GET /api/users/:id // Read single resource
POST /api/users // Create (idempotent for same data)
PUT /api/users/:id // Update (full replacement)
PATCH /api/users/:id // Update (partial)
DELETE /api/users/:id // Delete
// ❌ Avoid: Using GET for mutations
GET /api/users/:id/delete // ❌ Wrong!

Consistent Naming Conventions

// ✅ Use plural nouns for collections
GET /api/users
GET /api/posts
GET /api/comments
// ✅ Use nested resources for relationships
GET /api/users/:id/posts
GET /api/posts/:id/comments
// ❌ Avoid inconsistent naming
GET /api/user // ❌ Singular
GET /api/getPosts // ❌ Verb in URL
GET /api/user_posts // ❌ Underscores

Proper Status Codes

// ✅ Use appropriate HTTP status codes
res.status(200).json(data); // Success
res.status(201).json(created); // Created
res.status(204).send(); // No content (delete)
res.status(400).json({ error }); // Bad request
res.status(401).json({ error }); // Unauthorized
res.status(403).json({ error }); // Forbidden
res.status(404).json({ error }); // Not found
res.status(500).json({ error }); // Server error

Pagination for Collections

// ✅ Implement pagination
app.get("/api/users", async (req, res) => {
const page = parseInt(req.query.page) || 1;
const limit = parseInt(req.query.limit) || 20;
const offset = (page - 1) * limit;
const { users, total } = await db.users.findAndCount({
limit,
offset,
});
res.json({
data: users,
pagination: {
page,
limit,
total,
totalPages: Math.ceil(total / limit),
},
});
});

GraphQL Best Practices

Use Query Complexity Analysis

// ✅ Prevent expensive queries
import { createComplexityLimitRule } from "graphql-query-complexity";
const complexityLimitRule = createComplexityLimitRule(1000, {
scalarCost: 1,
objectCost: 10,
listFactor: 10,
});
const server = new ApolloServer({
typeDefs,
resolvers,
validationRules: [complexityLimitRule],
});
// Prevents queries like:
// query {
// users {
// posts {
// comments {
// author {
// posts { ... } // Too deep!
// }
// }
// }
// }
// }

Implement Query Depth Limiting

// ✅ Limit query depth
import depthLimit from "graphql-depth-limit";
const server = new ApolloServer({
typeDefs,
resolvers,
validationRules: [depthLimit(5)], // Max depth of 5
});

Use DataLoader for Batching

// ✅ Always use DataLoader to prevent N+1 queries
import DataLoader from "dataloader";
const userLoader = new DataLoader(async (ids) => {
const users = await db.users.findByIds(ids);
return ids.map((id) => users.find((u) => u.id === id));
});
const resolvers = {
Post: {
author: (post) => userLoader.load(post.authorId),
},
};

Implement Field-Level Permissions

// ✅ Control field access based on permissions
const resolvers = {
User: {
email: (user, args, context) => {
// Only return email if user is viewing their own profile
if (context.userId === user.id) {
return user.email;
}
return null;
},
privateData: (user, args, context) => {
// Only admins can see this
if (context.userRole === "admin") {
return user.privateData;
}
return null;
},
},
};

Use Fragments for Reusability

# ✅ Define reusable fragments
fragment UserBasicInfo on User {
id
name
avatar
}
fragment UserFullInfo on User {
...UserBasicInfo
email
bio
posts {
title
}
}
# Use fragments in queries
query {
user(id: 1) {
...UserFullInfo
}
}

Security Best Practices

⚠️ Rate Limiting

// ✅ Implement rate limiting for both REST and GraphQL
import rateLimit from "express-rate-limit";
// REST API rate limiting
const apiLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // Limit each IP to 100 requests per windowMs
});
app.use("/api/", apiLimiter);
// GraphQL rate limiting
const graphqlLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 50, // Fewer requests for GraphQL (queries can be expensive)
});
app.use("/graphql", graphqlLimiter);

⚠️ Input Validation

// ✅ Validate inputs
import { z } from "zod";
// REST validation
const createUserSchema = z.object({
name: z.string().min(1).max(100),
email: z.string().email(),
});
app.post("/api/users", async (req, res) => {
try {
const data = createUserSchema.parse(req.body);
const user = await db.users.create(data);
res.status(201).json(user);
} catch (error) {
res.status(400).json({ error: error.message });
}
});
// GraphQL validation (using schema directives)
const typeDefs = gql`
directive @validateEmail on ARGUMENT_DEFINITION | INPUT_FIELD_DEFINITION
input CreateUserInput {
name: String!
email: String! @validateEmail
}
`;

Conclusion

Choosing between GraphQL vs REST API isn’t about finding the “better” technology—it’s about selecting the right tool for your specific use case. Both approaches have their strengths and excel in different scenarios.

Choose REST API when:

  • You need simple CRUD operations
  • HTTP caching is critical
  • You’re building public APIs
  • Your team is more familiar with REST
  • You need file uploads/downloads
  • You’re working with microservices

Choose GraphQL when:

  • You have complex data relationships
  • Mobile performance is critical
  • Multiple clients need different data shapes
  • You want to reduce over-fetching
  • You need real-time subscriptions
  • Frontend developers need flexibility

💡 Hybrid Approach: Many successful companies use both! You might use REST for file operations and GraphQL for complex data queries. The key is understanding when each tool shines.

Remember, the best API is one that serves your users effectively, is maintainable by your team, and scales with your application. Start with what your team knows best, and evolve your architecture as your needs grow.

For more API design guidance, explore our REST API Cheatsheet and GraphQL Cheatsheet, and consider the specific requirements of your project before making a final decision.