The GraphQL vs REST debate continues to evolve as both technologies mature. In 2026, the choice isn't about which is better, but which is right for your specific use case. This guide provides a comprehensive comparison with practical implementation examples to help you make the right architectural decision.
Core Differences
REST organizes APIs around resources with standard HTTP methods, while GraphQL provides a single endpoint with a query language for flexible data fetching. Each approach has distinct advantages for different scenarios.
- REST: Resource-based, multiple endpoints, HTTP caching, simpler tooling
- GraphQL: Query-based, single endpoint, precise data fetching, strong typing
REST API Implementation
Modern REST APIs follow best practices including proper resource naming, HTTP method semantics, versioning, and comprehensive error handling.
// Modern REST API with Express and TypeScript
import express, { Request, Response, NextFunction } from 'express';
import { z } from 'zod';
const app = express();
// Validation schemas
const CreateUserSchema = z.object({
email: z.string().email(),
name: z.string().min(2).max(100),
role: z.enum(['user', 'admin']).default('user'),
});
const UpdateUserSchema = CreateUserSchema.partial();
const PaginationSchema = z.object({
page: z.coerce.number().min(1).default(1),
limit: z.coerce.number().min(1).max(100).default(20),
sort: z.string().optional(),
order: z.enum(['asc', 'desc']).default('desc'),
});
// Middleware for validation
function validate<T>(schema: z.ZodSchema<T>) {
return (req: Request, res: Response, next: NextFunction) => {
try {
req.body = schema.parse(req.body);
next();
} catch (error) {
if (error instanceof z.ZodError) {
res.status(400).json({
error: 'Validation failed',
details: error.errors,
});
return;
}
next(error);
}
};
}
// Resource: Users
// GET /api/v1/users - List users
app.get('/api/v1/users', async (req: Request, res: Response) => {
const { page, limit, sort, order } = PaginationSchema.parse(req.query);
const users = await db.user.findMany({
skip: (page - 1) * limit,
take: limit,
orderBy: sort ? { [sort]: order } : { createdAt: 'desc' },
});
const total = await db.user.count();
res.json({
data: users,
pagination: {
page,
limit,
total,
totalPages: Math.ceil(total / limit),
},
links: {
self: `/api/v1/users?page=${page}&limit=${limit}`,
next: page * limit < total
? `/api/v1/users?page=${page + 1}&limit=${limit}`
: null,
prev: page > 1
? `/api/v1/users?page=${page - 1}&limit=${limit}`
: null,
},
});
});
// GET /api/v1/users/:id - Get single user
app.get('/api/v1/users/:id', async (req: Request, res: Response) => {
const user = await db.user.findUnique({
where: { id: req.params.id },
include: {
posts: { take: 5, orderBy: { createdAt: 'desc' } },
profile: true,
},
});
if (!user) {
res.status(404).json({ error: 'User not found' });
return;
}
res.json({ data: user });
});
// POST /api/v1/users - Create user
app.post(
'/api/v1/users',
validate(CreateUserSchema),
async (req: Request, res: Response) => {
const user = await db.user.create({
data: req.body,
});
res.status(201).json({
data: user,
links: { self: `/api/v1/users/${user.id}` },
});
}
);
// PATCH /api/v1/users/:id - Update user
app.patch(
'/api/v1/users/:id',
validate(UpdateUserSchema),
async (req: Request, res: Response) => {
const user = await db.user.update({
where: { id: req.params.id },
data: req.body,
});
res.json({ data: user });
}
);
// DELETE /api/v1/users/:id - Delete user
app.delete('/api/v1/users/:id', async (req: Request, res: Response) => {
await db.user.delete({ where: { id: req.params.id } });
res.status(204).send();
});
// Nested resource: User's posts
// GET /api/v1/users/:id/posts
app.get('/api/v1/users/:userId/posts', async (req: Request, res: Response) => {
const posts = await db.post.findMany({
where: { authorId: req.params.userId },
});
res.json({ data: posts });
});GraphQL Implementation
GraphQL provides a strongly-typed schema and flexible querying capabilities. Modern implementations use code-first approaches with automatic type generation.
// GraphQL API with Apollo Server and Pothos
import { createServer } from 'node:http';
import { createYoga } from 'graphql-yoga';
import SchemaBuilder from '@pothos/core';
import PrismaPlugin from '@pothos/plugin-prisma';
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
// Schema Builder with Prisma integration
const builder = new SchemaBuilder<{
PrismaTypes: PrismaTypes;
Context: { prisma: PrismaClient; userId?: string };
}>({
plugins: [PrismaPlugin],
prisma: { client: prisma },
});
// Define User type
builder.prismaObject('User', {
fields: (t) => ({
id: t.exposeID('id'),
email: t.exposeString('email'),
name: t.exposeString('name'),
role: t.exposeString('role'),
createdAt: t.expose('createdAt', { type: 'DateTime' }),
posts: t.relation('posts', {
args: {
limit: t.arg.int({ defaultValue: 10 }),
offset: t.arg.int({ defaultValue: 0 }),
},
query: (args) => ({
take: args.limit ?? 10,
skip: args.offset ?? 0,
orderBy: { createdAt: 'desc' },
}),
}),
postCount: t.relationCount('posts'),
profile: t.relation('profile', { nullable: true }),
}),
});
// Define Post type
builder.prismaObject('Post', {
fields: (t) => ({
id: t.exposeID('id'),
title: t.exposeString('title'),
content: t.exposeString('content'),
published: t.exposeBoolean('published'),
author: t.relation('author'),
comments: t.relation('comments'),
createdAt: t.expose('createdAt', { type: 'DateTime' }),
}),
});
// Input types
const CreateUserInput = builder.inputType('CreateUserInput', {
fields: (t) => ({
email: t.string({ required: true }),
name: t.string({ required: true }),
role: t.string({ defaultValue: 'user' }),
}),
});
const UserFilterInput = builder.inputType('UserFilterInput', {
fields: (t) => ({
role: t.string(),
search: t.string(),
}),
});
// Query type
builder.queryType({
fields: (t) => ({
// Get single user
user: t.prismaField({
type: 'User',
nullable: true,
args: { id: t.arg.id({ required: true }) },
resolve: (query, _root, args, ctx) =>
ctx.prisma.user.findUnique({
...query,
where: { id: args.id },
}),
}),
// List users with filtering and pagination
users: t.prismaConnection({
type: 'User',
cursor: 'id',
args: {
filter: t.arg({ type: UserFilterInput }),
},
resolve: (query, _root, args, ctx) => {
const where: any = {};
if (args.filter?.role) {
where.role = args.filter.role;
}
if (args.filter?.search) {
where.OR = [
{ name: { contains: args.filter.search, mode: 'insensitive' } },
{ email: { contains: args.filter.search, mode: 'insensitive' } },
];
}
return ctx.prisma.user.findMany({
...query,
where,
});
},
}),
// Get current user
me: t.prismaField({
type: 'User',
nullable: true,
resolve: (query, _root, _args, ctx) => {
if (!ctx.userId) return null;
return ctx.prisma.user.findUnique({
...query,
where: { id: ctx.userId },
});
},
}),
}),
});
// Mutation type
builder.mutationType({
fields: (t) => ({
createUser: t.prismaField({
type: 'User',
args: { input: t.arg({ type: CreateUserInput, required: true }) },
resolve: (query, _root, args, ctx) =>
ctx.prisma.user.create({
...query,
data: args.input,
}),
}),
updateUser: t.prismaField({
type: 'User',
args: {
id: t.arg.id({ required: true }),
input: t.arg({ type: CreateUserInput, required: true }),
},
resolve: (query, _root, args, ctx) =>
ctx.prisma.user.update({
...query,
where: { id: args.id },
data: args.input,
}),
}),
deleteUser: t.field({
type: 'Boolean',
args: { id: t.arg.id({ required: true }) },
resolve: async (_root, args, ctx) => {
await ctx.prisma.user.delete({ where: { id: args.id } });
return true;
},
}),
}),
});
// Build and serve
const schema = builder.toSchema();
const yoga = createYoga({
schema,
context: ({ request }) => ({
prisma,
userId: request.headers.get('x-user-id') ?? undefined,
}),
});
const server = createServer(yoga);
server.listen(4000);Performance Comparison
Both approaches can be optimized for high performance. REST benefits from HTTP caching, while GraphQL requires careful attention to query complexity and data loading.
// REST: HTTP Caching
import { Redis } from 'ioredis';
const redis = new Redis();
// Cache middleware for REST
function cacheMiddleware(ttl: number) {
return async (req: Request, res: Response, next: NextFunction) => {
if (req.method !== 'GET') {
return next();
}
const cacheKey = `api:${req.originalUrl}`;
const cached = await redis.get(cacheKey);
if (cached) {
res.set('X-Cache', 'HIT');
res.set('Cache-Control', `public, max-age=${ttl}`);
return res.json(JSON.parse(cached));
}
// Store original json method
const originalJson = res.json.bind(res);
res.json = (body: any) => {
redis.setex(cacheKey, ttl, JSON.stringify(body));
res.set('X-Cache', 'MISS');
res.set('Cache-Control', `public, max-age=${ttl}`);
return originalJson(body);
};
next();
};
}
// Apply to routes
app.get('/api/v1/users', cacheMiddleware(60), listUsers);
app.get('/api/v1/users/:id', cacheMiddleware(300), getUser);
// GraphQL: DataLoader for N+1 prevention
import DataLoader from 'dataloader';
function createLoaders(prisma: PrismaClient) {
return {
userLoader: new DataLoader<string, User | null>(async (ids) => {
const users = await prisma.user.findMany({
where: { id: { in: [...ids] } },
});
const userMap = new Map(users.map(u => [u.id, u]));
return ids.map(id => userMap.get(id) ?? null);
}),
postsByAuthorLoader: new DataLoader<string, Post[]>(async (authorIds) => {
const posts = await prisma.post.findMany({
where: { authorId: { in: [...authorIds] } },
});
const postsByAuthor = new Map<string, Post[]>();
posts.forEach(post => {
const existing = postsByAuthor.get(post.authorId) ?? [];
postsByAuthor.set(post.authorId, [...existing, post]);
});
return authorIds.map(id => postsByAuthor.get(id) ?? []);
}),
};
}
// GraphQL: Query Complexity Analysis
import { createComplexityLimitRule } from 'graphql-validation-complexity';
const complexityLimit = createComplexityLimitRule(1000, {
onCost: (cost) => {
console.log('Query complexity:', cost);
},
formatErrorMessage: (cost) =>
`Query too complex: ${cost}. Maximum allowed: 1000`,
});
// GraphQL: Response Caching
import { useResponseCache } from '@graphql-yoga/plugin-response-cache';
const yoga = createYoga({
schema,
plugins: [
useResponseCache({
session: (request) => request.headers.get('authorization'),
ttl: 60_000, // 1 minute
// Don't cache mutations
enabled: ({ request }) => request.method === 'POST',
}),
],
});When to Use Each
Decision Framework
Choose REST when:
- Simple CRUD operations dominate
- HTTP caching is critical
- Team is less experienced with GraphQL
- Public API with many consumers
- Microservices with clear boundaries
Choose GraphQL when:
- Complex data requirements with relationships
- Mobile apps needing bandwidth optimization
- Rapid frontend iteration required
- Multiple client types (web, mobile, IoT)
- Strong typing is a priority
Hybrid Approach: BFF Pattern
Many organizations use both: REST for backend microservices and GraphQL as a Backend-for-Frontend (BFF) layer that aggregates data for clients.
// BFF Pattern: GraphQL aggregating REST microservices
import { RESTDataSource } from '@apollo/datasource-rest';
class UsersAPI extends RESTDataSource {
override baseURL = 'http://users-service:3001/api/v1/';
async getUser(id: string): Promise<User> {
return this.get(`users/${id}`);
}
async getUsers(params: { page?: number; limit?: number }): Promise<User[]> {
return this.get('users', { params });
}
}
class PostsAPI extends RESTDataSource {
override baseURL = 'http://posts-service:3002/api/v1/';
async getPostsByAuthor(authorId: string): Promise<Post[]> {
return this.get(`users/${authorId}/posts`);
}
}
// GraphQL resolvers using REST data sources
const resolvers = {
Query: {
user: async (_, { id }, { dataSources }) => {
return dataSources.usersAPI.getUser(id);
},
},
User: {
posts: async (user, _, { dataSources }) => {
return dataSources.postsAPI.getPostsByAuthor(user.id);
},
},
};
// Apollo Server with data sources
const server = new ApolloServer({
typeDefs,
resolvers,
});
const { url } = await startStandaloneServer(server, {
context: async () => ({
dataSources: {
usersAPI: new UsersAPI(),
postsAPI: new PostsAPI(),
},
}),
});Conclusion
Both REST and GraphQL are mature, production-ready technologies. REST excels in simplicity and caching, while GraphQL shines with complex data requirements and client flexibility. Many successful applications use both, leveraging each where it fits best.
Need help designing your API architecture? Contact Jishu Labs for expert guidance on building scalable, maintainable APIs.
About Michael Torres
Michael Torres is the Backend Lead at Jishu Labs specializing in API architecture and distributed systems. He has designed APIs serving billions of requests for enterprise clients.