GraphQL vs REST API - When to Use Which and How to Choose?
GraphQL REST API
porownaniaGraphQL vs REST API - A Comprehensive Comparison of API Architectures
Choosing the right API architecture is one of the most fundamental decisions in any web project. For over a decade, REST (Representational State Transfer) was the undisputed standard for building programmatic interfaces. In 2015, Facebook publicly released GraphQL - a query language for APIs designed to solve the problems they encountered while developing their mobile application. Today, both approaches have their place in the ecosystem - but when should you use which? In this article, we will thoroughly compare GraphQL and REST API from every angle.
What Is REST API?#
REST is an architectural style based on resources. Each resource (e.g., user, product, order) has its own unique URL, and operations are performed using standard HTTP methods.
Core Principles of REST#
- Resources identified by URLs -
/api/users/1,/api/products/42 - HTTP methods as operations - GET (read), POST (create), PUT/PATCH (update), DELETE (remove)
- Statelessness - each request contains all the information needed for processing
- Uniform interface - consistent conventions across all endpoints
- Caching layer - responses can be cached based on HTTP headers
REST API Example#
// Express.js Server - REST API
import express from 'express';
const app = express();
app.use(express.json());
// GET /api/users - list users
app.get('/api/users', async (req, res) => {
const users = await db.user.findMany();
res.json(users);
});
// GET /api/users/:id - user details
app.get('/api/users/:id', async (req, res) => {
const user = await db.user.findUnique({
where: { id: Number(req.params.id) },
include: { posts: true, profile: true },
});
if (!user) return res.status(404).json({ error: 'Not found' });
res.json(user);
});
// POST /api/users - create user
app.post('/api/users', async (req, res) => {
const user = await db.user.create({ data: req.body });
res.status(201).json(user);
});
// PUT /api/users/:id - update user
app.put('/api/users/:id', async (req, res) => {
const user = await db.user.update({
where: { id: Number(req.params.id) },
data: req.body,
});
res.json(user);
});
// DELETE /api/users/:id - delete user
app.delete('/api/users/:id', async (req, res) => {
await db.user.delete({ where: { id: Number(req.params.id) } });
res.status(204).send();
});
What Is GraphQL?#
GraphQL is a query language and runtime for APIs that allows the client to precisely specify what data it needs. Instead of multiple endpoints, there is a single entry point and a flexible query system based on a type schema.
Core Concepts of GraphQL#
- Schema and types - a contract between client and server defining the shape of data
- Queries - reading data with precise field selection
- Mutations - operations that modify data
- Subscriptions - real-time data via WebSocket
- Resolvers - functions defining how data is fetched for each field
GraphQL API Example#
// GraphQL Schema
const typeDefs = `#graphql
type User {
id: ID!
name: String!
email: String!
posts: [Post!]!
profile: Profile
}
type Post {
id: ID!
title: String!
content: String!
author: User!
createdAt: String!
}
type Profile {
id: ID!
bio: String
avatar: String
}
type Query {
users: [User!]!
user(id: ID!): User
posts(limit: Int, offset: Int): [Post!]!
}
type Mutation {
createUser(input: CreateUserInput!): User!
updateUser(id: ID!, input: UpdateUserInput!): User!
deleteUser(id: ID!): Boolean!
}
input CreateUserInput {
name: String!
email: String!
password: String!
}
input UpdateUserInput {
name: String
email: String
}
`;
// Resolvers
const resolvers = {
Query: {
users: () => db.user.findMany(),
user: (_, { id }) => db.user.findUnique({ where: { id } }),
posts: (_, { limit, offset }) =>
db.post.findMany({ take: limit, skip: offset }),
},
Mutation: {
createUser: (_, { input }) => db.user.create({ data: input }),
updateUser: (_, { id, input }) =>
db.user.update({ where: { id }, data: input }),
deleteUser: async (_, { id }) => {
await db.user.delete({ where: { id } });
return true;
},
},
User: {
posts: (parent) =>
db.post.findMany({ where: { authorId: parent.id } }),
profile: (parent) =>
db.profile.findUnique({ where: { userId: parent.id } }),
},
};
The Over-fetching and Under-fetching Problem#
This is arguably the most frequently cited argument in favor of GraphQL.
Over-fetching in REST#
Over-fetching occurs when an endpoint returns more data than the client actually needs.
// REST: GET /api/users/1
// Response includes ALL fields, even if you only need the name
{
"id": 1,
"name": "John Smith",
"email": "john@example.com",
"phone": "+1 555 123 4567",
"address": "123 Main Street, New York",
"bio": "Lorem ipsum dolor sit amet...",
"avatar": "/uploads/john.webp",
"createdAt": "2024-01-15T10:30:00Z",
"lastLogin": "2024-03-01T08:15:00Z",
"preferences": { "theme": "dark", "language": "en" }
}
Under-fetching in REST#
Under-fetching occurs when a single endpoint does not provide enough data and multiple requests are needed.
// You need: user + their posts + comments on posts
// REST requires three requests:
const user = await fetch('/api/users/1');
const posts = await fetch('/api/users/1/posts');
const comments = await fetch('/api/posts/42/comments');
The GraphQL Solution#
# One query - exactly the data you need
query {
user(id: "1") {
name
posts {
title
comments {
text
author {
name
}
}
}
}
}
GraphQL lets you fetch exactly the fields and relations you need in a single request. This is a massive advantage especially on mobile devices with limited bandwidth.
Schema and Type System#
GraphQL - Schema-first vs Code-first#
GraphQL enforces a type schema definition, creating a contract between frontend and backend.
// Code-first with @nestjs/graphql
@ObjectType()
export class User {
@Field(() => ID)
id: string;
@Field()
name: string;
@Field()
email: string;
@Field(() => [Post])
posts: Post[];
}
@InputType()
export class CreateUserInput {
@Field()
@IsNotEmpty()
name: string;
@Field()
@IsEmail()
email: string;
}
The GraphQL schema serves simultaneously as documentation and validation - the client knows exactly what data types to expect.
REST - OpenAPI/Swagger#
REST has no built-in type system, but it can be supplemented with the OpenAPI specification.
# openapi.yaml
paths:
/api/users/{id}:
get:
summary: Get user
parameters:
- name: id
in: path
required: true
schema:
type: integer
responses:
'200':
content:
application/json:
schema:
$ref: '#/components/schemas/User'
components:
schemas:
User:
type: object
properties:
id:
type: integer
name:
type: string
email:
type: string
format: email
In REST, the type schema is optional and requires additional tooling, which in practice is often neglected.
API Versioning#
REST - Versioning via URL or Headers#
// URL versioning
app.get('/api/v1/users', handleUsersV1);
app.get('/api/v2/users', handleUsersV2);
// Header versioning
app.get('/api/users', (req, res) => {
const version = req.headers['api-version'] || '1';
if (version === '2') return handleUsersV2(req, res);
return handleUsersV1(req, res);
});
REST API versioning is conceptually straightforward but expensive to maintain - each new version potentially duplicates code and requires supporting older versions.
GraphQL - Versionless Evolution#
type User {
id: ID!
name: String!
email: String!
# New field - old clients simply don't fetch it
phoneNumber: String
# Marking a field as deprecated
phone: String @deprecated(reason: "Use phoneNumber instead")
}
GraphQL does not need versioning. You add new fields and mark old ones as @deprecated. Clients only fetch what they need, so adding fields never breaks backward compatibility.
Caching#
REST - Native HTTP Caching#
REST works beautifully with HTTP cache layers because each resource has a unique URL.
// Cache headers in REST
app.get('/api/products/:id', (req, res) => {
res.set({
'Cache-Control': 'public, max-age=3600',
'ETag': `"product-${product.id}-${product.updatedAt}"`,
});
res.json(product);
});
// CDNs, browsers and proxies automatically cache responses
// GET /api/products/42 -> cache HIT (same URL = same resource)
GraphQL - Caching Requires Extra Work#
Since GraphQL uses a single endpoint and POST requests, traditional HTTP caching does not work.
// Apollo Client - normalized client-side cache
const client = new ApolloClient({
cache: new InMemoryCache({
typePolicies: {
User: {
keyFields: ['id'],
},
Post: {
keyFields: ['id'],
fields: {
comments: {
merge(existing = [], incoming) {
return [...existing, ...incoming];
},
},
},
},
},
}),
});
// Persisted Queries - converting POST to GET with a hash
// POST { query: "{ user(id: 1) { name } }" }
// becomes:
// GET /graphql?extensions={"persistedQuery":{"sha256Hash":"abc123..."}}
Apollo Client provides an advanced normalized cache, but it requires configuration. REST wins in terms of caching simplicity.
Error Handling#
REST - HTTP Status Codes#
// REST - standard HTTP codes
app.get('/api/users/:id', async (req, res) => {
try {
const user = await db.user.findUnique({
where: { id: Number(req.params.id) },
});
if (!user) {
return res.status(404).json({
error: 'NOT_FOUND',
message: 'User does not exist',
});
}
res.json(user);
} catch (error) {
res.status(500).json({
error: 'INTERNAL_ERROR',
message: 'Internal server error',
});
}
});
// Client: status 404 = resource not found, 401 = unauthorized, etc.
GraphQL - Errors in the Response Body#
// GraphQL always returns status 200
{
"data": {
"user": null
},
"errors": [
{
"message": "User does not exist",
"locations": [{ "line": 2, "column": 3 }],
"path": ["user"],
"extensions": {
"code": "NOT_FOUND",
"statusCode": 404
}
}
]
}
// Error handling in resolvers
import { GraphQLError } from 'graphql';
const resolvers = {
Query: {
user: async (_, { id }) => {
const user = await db.user.findUnique({ where: { id } });
if (!user) {
throw new GraphQLError('User does not exist', {
extensions: { code: 'NOT_FOUND', statusCode: 404 },
});
}
return user;
},
},
};
In GraphQL, the response almost always has a 200 status, with errors passed in the errors field. This can be confusing for monitoring tools that rely on HTTP status codes.
The N+1 Problem#
The N+1 problem is particularly significant in the GraphQL context because the client can request nested relations, generating many database queries.
The Problem#
# GraphQL Query
query {
users { # 1 SQL query: SELECT * FROM users
name
posts { # N SQL queries: SELECT * FROM posts WHERE author_id = ?
title # (one query per user)
}
}
}
If we have 100 users, we execute 101 database queries (1 + 100).
The Solution - DataLoader#
import DataLoader from 'dataloader';
// DataLoader batches queries and executes them in bulk
const postLoader = new DataLoader(async (userIds: string[]) => {
const posts = await db.post.findMany({
where: { authorId: { in: userIds } },
});
// Map results to corresponding userIds
const postsByUser = userIds.map((id) =>
posts.filter((post) => post.authorId === id)
);
return postsByUser;
});
// In the resolver
const resolvers = {
User: {
posts: (parent) => postLoader.load(parent.id),
},
};
// Now instead of 101 queries we have only 2:
// SELECT * FROM users
// SELECT * FROM posts WHERE author_id IN (1, 2, 3, ..., 100)
DataLoader is the standard solution to the N+1 problem in GraphQL. In REST, this problem is less acute because the server controls database queries - the client does not decide on nesting depth.
Subscriptions - Real-Time Data#
GraphQL Subscriptions#
// Schema
const typeDefs = `#graphql
type Subscription {
messageAdded(channelId: ID!): Message!
userStatusChanged: UserStatus!
}
type Message {
id: ID!
text: String!
author: User!
createdAt: String!
}
`;
// Subscription resolver
const resolvers = {
Subscription: {
messageAdded: {
subscribe: (_, { channelId }) =>
pubsub.asyncIterator([`MESSAGE_ADDED_${channelId}`]),
},
},
Mutation: {
sendMessage: async (_, { channelId, text }, { user }) => {
const message = await db.message.create({
data: { text, channelId, authorId: user.id },
});
pubsub.publish(`MESSAGE_ADDED_${channelId}`, {
messageAdded: message,
});
return message;
},
},
};
// Client - subscription in React with Apollo
const MESSAGES_SUBSCRIPTION = gql`
subscription OnMessageAdded($channelId: ID!) {
messageAdded(channelId: $channelId) {
id
text
author {
name
avatar
}
createdAt
}
}
`;
function ChatRoom({ channelId }) {
const { data, loading } = useSubscription(MESSAGES_SUBSCRIPTION, {
variables: { channelId },
});
// New messages appear automatically
return <MessageList messages={data?.messageAdded} />;
}
Subscriptions are a native GraphQL feature that REST does not offer without additional mechanisms (WebSocket, Server-Sent Events).
Tooling and Ecosystem#
GraphQL#
| Tool | Description | |------|-------------| | Apollo Client | Most popular GraphQL client with cache and state management | | Apollo Server | Production-ready GraphQL server for Node.js | | Relay | GraphQL client from Meta, optimized for performance | | GraphQL Code Generator | Automatic TypeScript type generation from schema | | GraphiQL / Apollo Studio | Interactive IDE for exploring APIs | | Hasura | Automatic GraphQL API from PostgreSQL database | | Prisma | ORM with native support for GraphQL schema generation |
REST#
| Tool | Description | |------|-------------| | Postman | Most popular API testing tool | | Swagger/OpenAPI | Standard for documenting REST APIs | | Insomnia | Lightweight alternative to Postman | | cURL | Universal command-line tool | | Hoppscotch | Open-source browser-based API testing tool | | Express.js / Fastify | Popular HTTP frameworks | | json-server | Quick REST mock server from a JSON file |
The REST ecosystem is more mature and broader. GraphQL is catching up, and tools like Apollo and GraphQL Code Generator significantly enhance Developer Experience.
Performance#
REST - Predictable Performance#
In REST, each endpoint has a defined complexity. The server controls what data is returned, making optimization straightforward.
// REST - easy per-endpoint optimization
app.get('/api/products', async (req, res) => {
const products = await db.product.findMany({
select: { id: true, name: true, price: true, thumbnail: true },
take: 20,
});
res.json(products);
});
GraphQL - Flexibility at the Cost of Control#
In GraphQL, the client decides the query depth, which can lead to complex queries that overload the server.
// Protection against complex queries
import depthLimit from 'graphql-depth-limit';
import { createComplexityLimitRule } from 'graphql-validation-complexity';
const server = new ApolloServer({
typeDefs,
resolvers,
validationRules: [
depthLimit(5), // Maximum nesting depth
createComplexityLimitRule(1000), // Complexity limit
],
});
GraphQL requires additional protective mechanisms - query depth limiting, complexity analysis, and per-query rate limiting (rather than per-endpoint as in REST).
When to Choose REST API?#
- Simple CRUD operations - REST naturally maps to database operations
- Heavy caching - HTTP caching works natively with REST
- Public APIs - REST is simpler for external developers to understand
- File uploads - REST handles multipart/form-data without extra configuration
- Microservices with simple contracts - REST is lighter and less complex
- Teams without GraphQL experience - REST has a gentler learning curve
- APIs with few clients - when over-fetching is not a concern
When to Choose GraphQL?#
- Multiple clients with different needs - mobile, web, IoT - each fetches different data
- Complex data relationships - nested queries eliminate multiple HTTP requests
- Rapidly evolving APIs - adding fields without versioning
- Real-time applications - built-in subscriptions
- Multi-service aggregation - GraphQL as an API Gateway (Federation)
- Developer Experience - autocomplete, typing, interactive documentation
- Mobile applications - minimizing data transfer is critical
The Hybrid Approach#
In practice, many teams combine both approaches, leveraging their respective strengths.
┌─────────────────────┐
│ API Gateway │
│ (GraphQL) │
└──────┬──────────────┘
│
┌──────────────┼──────────────┐
│ │ │
┌─────▼────┐ ┌─────▼────┐ ┌─────▼────┐
│ User │ │ Product │ │ Order │
│ Service │ │ Service │ │ Service │
│ (REST) │ │ (REST) │ │ (REST) │
└──────────┘ └──────────┘ └──────────┘
// GraphQL as an aggregation layer over REST microservices
const resolvers = {
Query: {
orderDetails: async (_, { orderId }) => {
const [order, user, products] = await Promise.all([
fetch(`http://orders-service/api/orders/${orderId}`).then(r => r.json()),
fetch(`http://users-service/api/users/${order.userId}`).then(r => r.json()),
fetch(`http://products-service/api/products?ids=${order.productIds.join(',')}`).then(r => r.json()),
]);
return { ...order, user, products };
},
},
};
A popular pattern is GraphQL Federation (Apollo Federation), where each microservice defines its portion of the GraphQL schema and a gateway merges them into a single cohesive schema.
Comparison Table#
| Aspect | REST | GraphQL | |--------|------|---------| | Architecture | Multiple endpoints, resources | Single endpoint, queries | | Data fetching | Fixed responses per endpoint | Client selects exact fields | | Over-fetching | Common | None | | Under-fetching | Common (multiple requests) | None (single query) | | Type system | Optional (OpenAPI) | Built-in (Schema) | | Versioning | URL or headers | Schema evolution | | Caching | Native HTTP cache | Requires Apollo/Relay | | Errors | HTTP status codes | Errors field in response | | Real-time | SSE/WebSocket (additional) | Built-in subscriptions | | File uploads | Native multipart | Requires extra configuration | | Learning curve | Low | Medium-high | | Tooling | Postman, cURL, Swagger | Apollo Studio, GraphiQL | | Performance | Predictable | Requires complexity protection |
Conclusion#
There is no definitive answer to the question "GraphQL or REST?". Both approaches have their strengths and use cases. REST remains an excellent choice for simple, well-defined APIs with heavy caching and public access. GraphQL excels in complex applications with multiple clients where data fetching flexibility and strong typing are paramount.
Market trends indicate growing adoption of GraphQL, particularly in frontend and mobile applications. At the same time, REST is not going away - both technologies will coexist, and the most effective teams know how to leverage each where it works best.
Need a Professional API for Your Project?#
At MDS Software Solutions Group, we design and implement high-performance APIs in both REST and GraphQL architectures. Our team will help you choose the optimal approach, design the data schema, and build a scalable solution tailored to your business needs.
Contact us to discuss your project - from requirements analysis and architectural consulting to full implementation and API maintenance.
Team of programming experts specializing in modern web technologies.