REST has dominated API design for over a decade. Its resource-oriented architecture, HTTP verb semantics, and stateless request model became the default way to build web services. GraphQL, developed internally at Facebook in 2012 and open-sourced in 2015, offers a fundamentally different approach: instead of the server defining fixed endpoints, the client describes exactly the data it needs in each request.
This is not a story of one replacing the other. REST and GraphQL solve different problems well. This guide provides a deep technical comparison — architecture, data fetching, mutations, subscriptions, caching, performance, error handling, and tooling — with working code examples and concrete guidance on when to use each.
Architecture: Endpoints vs. Schema
REST Architecture
REST organizes APIs around resources. Each resource has a URL, and HTTP methods (GET, POST, PUT, PATCH, DELETE) define operations on that resource:
GET /api/users # List users
GET /api/users/42 # Get user 42
POST /api/users # Create a user
PUT /api/users/42 # Replace user 42
PATCH /api/users/42 # Update user 42 fields
DELETE /api/users/42 # Delete user 42
GET /api/users/42/posts # Get user 42's posts
Each endpoint returns a fixed data structure defined by the server. The client has no control over which fields are included in the response — it gets everything the endpoint provides, whether it needs all of it or not.
GraphQL Architecture
GraphQL exposes a single endpoint (typically /graphql) and a typed schema that describes all available data and operations. The client sends a query specifying exactly which fields it needs:
# The schema defines the data model
type User {
id: ID!
name: String!
email: String!
avatar: String
role: String!
posts(limit: Int, offset: Int): [Post!]!
createdAt: DateTime!
}
type Post {
id: ID!
title: String!
excerpt: String
body: String!
author: User!
comments: [Comment!]!
publishedAt: DateTime
}
type Comment {
id: ID!
text: String!
author: User!
createdAt: DateTime!
}
type Query {
user(id: ID!): User
users(limit: Int, offset: Int): [User!]!
post(id: ID!): Post
posts(limit: Int, offset: Int, authorId: ID): [Post!]!
}
The schema serves as both the API contract and its documentation. Every type, field, and argument is explicitly defined with types, nullability rules, and descriptions.
Queries: The Over-Fetching and Under-Fetching Problem
The REST Problem
Consider a mobile app that needs to render a user profile screen showing the user’s name, their five most recent posts (title only), and comment counts. With REST:
// Request 1: Get user data (returns ALL user fields)
const user = await fetch('/api/users/42').then(r => r.json());
// Returns: id, name, email, avatar, role, bio, settings, createdAt...
// We only needed: name
// Request 2: Get posts (returns ALL post fields)
const posts = await fetch('/api/users/42/posts?limit=5').then(r => r.json());
// Returns: id, title, body, excerpt, tags, metadata, publishedAt...
// We only needed: title
// Request 3: Get comment counts per post (one request per post, or a custom endpoint)
const commentCounts = await Promise.all(
posts.map(post =>
fetch(`/api/posts/${post.id}/comments/count`).then(r => r.json())
)
);
Three or more network requests, most returning data the client discards. This is the dual problem of over-fetching (getting fields you do not need) and under-fetching (needing multiple requests to assemble the data for one screen).
The GraphQL Solution
query UserProfile {
user(id: "42") {
name
posts(limit: 5) {
title
comments {
id
}
}
}
}
One request. The response contains exactly the fields listed in the query — no extra data, no missing data. The network payload is smaller, and the client makes a single round trip instead of three or more.
Mutations: Writing Data
REST Mutations
// Create a post
const response = await fetch('/api/posts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: 'GraphQL vs REST',
body: 'A deep comparison...',
tags: ['api', 'graphql', 'rest'],
}),
});
const newPost = await response.json();
The response includes whatever fields the server decides to return. If you need the new post with its author details and comment count, you may need a follow-up GET request.
GraphQL Mutations
mutation CreatePost {
createPost(input: {
title: "GraphQL vs REST"
body: "A deep comparison..."
tags: ["api", "graphql", "rest"]
}) {
id
title
publishedAt
author {
name
}
}
}
The mutation response includes exactly the fields you request. After creating a post, you can immediately request the post’s ID, publication date, and author name in the same round trip. No follow-up query needed.
Input Validation
GraphQL’s type system validates inputs at the schema level. If the title field is defined as String! (non-nullable string), sending a null or missing title returns a validation error before your resolver code executes. REST APIs typically handle validation in application code or middleware.
Subscriptions: Real-Time Data
GraphQL includes a first-class subscription mechanism for real-time data. Clients subscribe to specific events and receive updates over a WebSocket connection:
subscription OnNewComment($postId: ID!) {
commentAdded(postId: $postId) {
id
text
author {
name
avatar
}
createdAt
}
}
The subscription specifies exactly which fields to receive when a new comment arrives. This is more targeted than REST approaches, which typically push the entire updated resource over a WebSocket or Server-Sent Events connection. For more on real-time web communication protocols, see our web development guides.
Caching
REST Caching Advantages
REST’s resource-oriented design aligns naturally with HTTP caching. Each URL represents a resource, and standard HTTP headers control caching behavior:
# Response headers
Cache-Control: public, max-age=3600
ETag: "v1-abc123"
Last-Modified: Mon, 09 Mar 2026 10:00:00 GMT
CDNs, browser caches, and reverse proxies all understand these headers. A GET /api/users/42 response cached at the CDN edge serves subsequent identical requests without touching the origin server. This is the single biggest advantage REST has over GraphQL.
GraphQL Caching Challenges
GraphQL sends all requests as POST to a single endpoint (/graphql). HTTP caches cannot distinguish between different queries because the URL is identical for every request. The query body determines what data is returned, and HTTP caches do not inspect request bodies.
Solutions exist but add complexity:
- Persisted queries — Map each query to a hash and send GET requests with the hash as a URL parameter:
GET /graphql?id=abc123. This restores HTTP cacheability. - Client-side normalized caches — Libraries like Apollo Client and Relay maintain a normalized, in-memory cache keyed by object type and ID. When a mutation updates a user, every query referencing that user automatically reflects the change.
- CDN-level GraphQL caching — Services like Stellate and GraphCDN parse GraphQL queries at the CDN edge and cache responses with per-type TTLs.
Performance Comparison
Network Performance
GraphQL reduces the number of round trips but sends larger request payloads (the query string). For complex data requirements across related resources, GraphQL almost always wins because a single round trip outperforms three to five sequential requests, especially on high-latency mobile networks.
For simple, single-resource fetches, the difference is negligible. GET /api/users/42 and a GraphQL query for the same data have comparable latency.
Server Performance
GraphQL introduces the N+1 query problem. A query requesting 50 users with their posts can trigger 50 database queries (one per user) if the resolver is naive:
// Without DataLoader — N+1 problem
const resolvers = {
User: {
posts: async (user) => {
// This runs once PER user in the list
return db.posts.findAll({ where: { authorId: user.id } });
},
},
};
// With DataLoader — batched queries
const postLoader = new DataLoader(async (userIds) => {
const posts = await db.posts.findAll({
where: { authorId: userIds },
});
return userIds.map(id => posts.filter(p => p.authorId === id));
});
const resolvers = {
User: {
posts: (user) => postLoader.load(user.id),
},
};
DataLoader (developed by Facebook) batches and deduplicates database queries within a single request. It collects all load(id) calls in the current tick, batches them into a single database query, and distributes the results. This is essential for any production GraphQL server.
Query Complexity and Depth Limiting
Because GraphQL clients control query structure, a malicious or careless query can request deeply nested data that overwhelms the server:
# Potentially expensive query
query {
users(limit: 1000) {
posts(limit: 100) {
comments(limit: 100) {
author {
posts(limit: 100) {
comments(limit: 100) {
author { name }
}
}
}
}
}
}
}
Production GraphQL servers implement query complexity analysis and depth limiting to prevent abuse. Libraries like graphql-depth-limit and graphql-query-complexity reject queries that exceed configurable thresholds.
Error Handling
REST Error Handling
REST uses HTTP status codes: 200 for success, 400 for bad requests, 401 for authentication failures, 404 for not found, 500 for server errors. Clients check the status code first, then parse the response body for details.
GraphQL Error Handling
GraphQL always returns HTTP 200. Errors appear in an errors array alongside any partial data that resolved successfully:
{
"data": {
"user": {
"name": "Jane Developer",
"posts": null
}
},
"errors": [
{
"message": "Not authorized to view posts",
"path": ["user", "posts"],
"extensions": {
"code": "FORBIDDEN"
}
}
]
}
Partial responses are a feature, not a bug. The client receives the user’s name even though the posts query failed. This is useful when different parts of the response have different authorization rules or data source availability.
Tooling and Developer Experience
GraphQL Tooling Advantages
- Self-documenting API — The schema is the documentation. Tools like GraphiQL and Apollo Sandbox let developers explore the schema, write queries with autocomplete, and see results in real time.
- Type generation — Tools like GraphQL Code Generator produce TypeScript types from the schema, ensuring type safety across the full stack. If a field type changes in the schema, the TypeScript compiler catches every affected file.
- Schema validation — Breaking changes (removing a field, changing a type) are detectable at build time using schema diffing tools like GraphQL Inspector.
REST Tooling
- OpenAPI / Swagger — The OpenAPI specification provides schema definition and documentation generation for REST APIs. Not as automatic as GraphQL’s introspection, but widely supported and well-understood.
- Postman — The standard tool for exploring and testing REST endpoints. Collections, environments, and automated testing are mature features.
- cURL — REST endpoints are testable with a single command-line call. GraphQL requires constructing a JSON body with the query string.
Server Implementation
A Minimal GraphQL Server
import { createSchema, createYoga } from 'graphql-yoga';
import { createServer } from 'http';
const schema = createSchema({
typeDefs: `
type Query {
users: [User!]!
user(id: ID!): User
}
type User {
id: ID!
name: String!
email: String!
}
`,
resolvers: {
Query: {
users: () => db.users.findAll(),
user: (_, { id }) => db.users.findById(id),
},
},
});
const yoga = createYoga({ schema });
const server = createServer(yoga);
server.listen(4000, () => {
console.log('GraphQL server running at http://localhost:4000/graphql');
});
GraphQL Yoga (from The Guild) is one of several production-ready GraphQL server libraries. Apollo Server, Mercurius (for Fastify), and Pothos (schema-builder approach) are popular alternatives. The choice depends on your existing framework ecosystem.
When to Use GraphQL
- Mobile applications — Bandwidth is limited and round trips are expensive. GraphQL’s single-request model is a measurable improvement.
- Complex data relationships — Applications with deeply nested or interconnected data (social networks, project management tools, e-commerce catalogs) benefit from GraphQL’s ability to traverse relationships in a single query.
- Multiple client types — When a web app, mobile app, and smart TV app all consume the same API but need different data shapes, GraphQL lets each client request exactly what it needs without endpoint proliferation.
- Rapid iteration — Adding a field to a GraphQL type makes it immediately available to all clients. No new endpoint. No API versioning discussion. No server deployment required for the client to access new data.
- Microservice aggregation — A GraphQL gateway (Apollo Federation, GraphQL Mesh) can stitch multiple backend services into a unified API that clients query as a single graph.
When to Stick with REST
- Simple CRUD applications — If your API maps cleanly to resources with predictable data needs, REST is simpler to implement and maintain.
- Public APIs — HTTP caching, simple authentication, and familiar conventions make REST easier for third-party developers to consume.
- File operations — File uploads and downloads are straightforward in REST (multipart forms, streaming responses). GraphQL requires workarounds for binary data.
- Existing team expertise — The learning curve for GraphQL (schema design, resolver patterns, DataLoader, client-side caching) is non-trivial. If the team is productive with REST and the use case does not demand GraphQL’s features, switching has a real cost.
- Heavy caching requirements — Applications where CDN caching at the HTTP level is critical (content delivery, high-traffic read APIs) are easier to optimize with REST.
The Hybrid Approach
Many production applications use both. REST handles simple CRUD operations, file uploads, webhooks, and public APIs. GraphQL handles complex data fetching for internal frontends. This hybrid approach is not a compromise — it uses each tool where it is strongest. A well-configured development environment with proper language server support makes working with both APIs seamless.
Frequently Asked Questions
Is GraphQL faster than REST?
It depends on the use case. For complex screens requiring data from multiple related resources, GraphQL is faster because it eliminates multiple round trips. For simple, single-resource fetches, there is no meaningful speed difference. Server-side, GraphQL can be slower if resolvers are not optimized with DataLoader or similar batching mechanisms. The performance comparison is about architecture fit, not inherent speed.
Can GraphQL completely replace REST in a project?
Technically, yes. Practically, some use cases are clunky in GraphQL: file uploads, simple webhooks, server-sent events for streaming data, and APIs consumed by third parties who expect REST conventions. Most large-scale projects that adopt GraphQL keep REST endpoints for these specific use cases rather than forcing everything through a single paradigm.
How do you version a GraphQL API?
You generally do not. GraphQL APIs evolve by adding new fields and types without removing old ones. Deprecated fields are marked with the @deprecated directive and usage is tracked. When no clients query a deprecated field, it can be safely removed. This is fundamentally different from REST versioning (v1, v2 URL prefixes) and eliminates the problem of maintaining multiple API versions simultaneously.
What is the N+1 problem in GraphQL and how do you solve it?
The N+1 problem occurs when a list query triggers one additional database query for each item in the list. Requesting 50 users with their posts fires 1 query for the user list plus 50 queries for each user’s posts — 51 queries total. The solution is DataLoader, a utility that batches and deduplicates these individual queries into a single bulk query. DataLoader is a requirement, not an optimization, for any GraphQL server accessing a database.