Loading content...
The GraphQL vs REST debate has evolved significantly. Both technologies have matured, adopted best practices from each other, and found their ideal use cases. This comprehensive guide will help you make the right choice for your specific needs in 2026.
Core Principles:
Example REST API:
// Multiple endpoints for different resources
GET /api/users // List users
GET /api/users/:id // Get user details
POST /api/users // Create user
PUT /api/users/:id // Update user
DELETE /api/users/:id // Delete user
GET /api/users/:id/posts // Get user's posts
GET /api/posts/:id // Get post details
GET /api/posts/:id/comments // Get post comments
Core Principles:
Example GraphQL API:
# Single endpoint with flexible queries
query {
user(id: "123") {
id
name
email
posts {
id
title
comments {
id
text
author {
name
}
}
}
}
}
# Mutation for updates
mutation {
createPost(input: {
title: "New Post"
content: "Content here"
}) {
id
title
createdAt
}
}
# Subscription for real-time updates
subscription {
postCreated {
id
title
author {
name
}
}
}
REST: Over-fetching and Under-fetching:
// REST: Getting user profile with posts
// Problem: Multiple requests needed
// Request 1: Get user
const user = await fetch('/api/users/123')
// Returns: { id, name, email, avatar, bio, createdAt, ... }
// Over-fetching: We get fields we don't need
// Request 2: Get user's posts
const posts = await fetch('/api/users/123/posts')
// Returns: [{ id, title, content, createdAt, ... }]
// Request 3: Get each post's comments (N+1 problem)
for (const post of posts) {
const comments = await fetch(`/api/posts/${post.id}/comments`)
// More over-fetching...
}
// Result: 1 + 1 + N requests (slow!)
GraphQL: Precise Data Fetching:
// GraphQL: Single request, exact data
const { data } = await graphqlClient.query({
query: gql`
query UserProfile($userId: ID!) {
user(id: $userId) {
name
avatar
posts {
id
title
commentCount
comments(limit: 3) {
text
author {
name
}
}
}
}
}
`,
variables: { userId: '123' }
})
// Result: 1 request, only needed fields
Performance Comparison:
Scenario: User profile with 10 posts and latest 3 comments per post
REST:
- Requests: 12 (1 user + 1 posts + 10 comments)
- Total time: 600ms (50ms per request)
- Data transferred: 145KB
- Unused fields: ~40%
GraphQL:
- Requests: 1
- Total time: 85ms
- Data transferred: 52KB
- Unused fields: 0%
Result: GraphQL is 7x faster with 64% less data
REST with OpenAPI/Swagger:
# openapi.yaml
openapi: 3.0.0
info:
title: User API
version: 1.0.0
paths:
/users/{userId}:
get:
parameters:
- name: userId
in: path
required: true
schema:
type: string
responses:
'200':
description: User found
content:
application/json:
schema:
$ref: '#/components/schemas/User'
'404':
description: User not found
components:
schemas:
User:
type: object
properties:
id:
type: string
name:
type: string
email:
type: string
format: email
// Generated TypeScript types
import { UserApi } from './generated/api'
const api = new UserApi()
const user = await api.getUser({ userId: '123' })
// user is fully typed ✅
GraphQL with Code Generation:
# schema.graphql
type User {
id: ID!
name: String!
email: String!
posts: [Post!]!
}
type Post {
id: ID!
title: String!
content: String!
author: User!
comments: [Comment!]!
}
type Query {
user(id: ID!): User
post(id: ID!): Post
}
// Generated TypeScript types with GraphQL Codegen
import { useUserProfileQuery } from './generated/graphql'
function UserProfile({ userId }: { userId: string }) {
const { data, loading, error } = useUserProfileQuery({
variables: { userId }
})
// data.user is fully typed ✅
// data.user.posts is fully typed ✅
// data.user.posts[0].comments is fully typed ✅
return (
<div>
<h1>{data?.user?.name}</h1>
{data?.user?.posts.map(post => (
<div key={post.id}>{post.title}</div>
))}
</div>
)
}
REST: HTTP Caching:
// REST leverages standard HTTP caching
app.get('/api/users/:id', async (req, res) => {
const user = await db.users.findById(req.params.id)
// Set cache headers
res.set('Cache-Control', 'public, max-age=300') // 5 minutes
res.set('ETag', generateETag(user))
res.json(user)
})
// Client-side (automatic with fetch)
const response = await fetch('/api/users/123')
// Browser automatically caches based on headers
Benefits:
GraphQL: Normalized Caching:
// Apollo Client with normalized cache
import { ApolloClient, InMemoryCache } from '@apollo/client'
const client = new ApolloClient({
uri: '/graphql',
cache: new InMemoryCache({
typePolicies: {
Query: {
fields: {
user: {
read(existing, { args, toReference }) {
return existing || toReference({
__typename: 'User',
id: args.id
})
}
}
}
},
User: {
fields: {
posts: {
merge(existing = [], incoming) {
return [...existing, ...incoming]
}
}
}
}
}
})
})
// Automatic cache updates
const { data } = await client.query({
query: USER_QUERY,
fetchPolicy: 'cache-first' // Use cache if available
})
// Update mutation automatically updates cache
await client.mutate({
mutation: UPDATE_USER,
variables: { id: '123', name: 'New Name' },
// Apollo automatically updates all queries using this user
})
Benefits:
REST: Server-Sent Events (SSE) or WebSockets:
// Server-Sent Events
app.get('/api/notifications/stream', (req, res) => {
res.setHeader('Content-Type', 'text/event-stream')
res.setHeader('Cache-Control', 'no-cache')
res.setHeader('Connection', 'keep-alive')
const sendNotification = (notification) => {
res.write(`data: ${JSON.stringify(notification)}\n\n`)
}
// Subscribe to notifications
const subscription = notificationService.subscribe(
req.user.id,
sendNotification
)
req.on('close', () => {
subscription.unsubscribe()
})
})
// Client
const eventSource = new EventSource('/api/notifications/stream')
eventSource.onmessage = (event) => {
const notification = JSON.parse(event.data)
handleNotification(notification)
}
GraphQL: Built-in Subscriptions:
// Server: GraphQL subscription
const typeDefs = gql`
type Subscription {
notificationAdded(userId: ID!): Notification!
}
type Notification {
id: ID!
message: String!
createdAt: DateTime!
}
`
const resolvers = {
Subscription: {
notificationAdded: {
subscribe: (_, { userId }, { pubsub }) => {
return pubsub.asyncIterator(`NOTIFICATION_${userId}`)
}
}
}
}
// Client: GraphQL subscription
const { data } = useSubscription(
gql`
subscription OnNotification($userId: ID!) {
notificationAdded(userId: $userId) {
id
message
createdAt
}
}
`,
{ variables: { userId: '123' } }
)
// Automatic updates when new notification arrives
REST: HTTP Status Codes:
// Clear, standard error codes
app.get('/api/users/:id', async (req, res) => {
try {
const user = await db.users.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'
})
}
})
// Client handling
const response = await fetch('/api/users/123')
if (!response.ok) {
if (response.status === 404) {
// Handle not found
} else if (response.status === 500) {
// Handle server error
}
}
GraphQL: Errors in Response:
// GraphQL always returns 200, errors in response
const result = await graphqlClient.query({
query: GET_USER,
variables: { id: '123' }
})
// Response structure:
{
data: {
user: null
},
errors: [
{
message: "User not found",
extensions: {
code: "USER_NOT_FOUND",
userId: "123"
},
path: ["user"]
}
]
}
// Partial success possible
{
data: {
user: {
id: "123",
name: "John",
posts: null // This field failed
}
},
errors: [
{
message: "Failed to fetch posts",
path: ["user", "posts"]
}
]
}
REST: URL or Header Versioning:
// URL versioning
app.get('/api/v1/users/:id', handlerV1)
app.get('/api/v2/users/:id', handlerV2)
// Header versioning
app.get('/api/users/:id', (req, res) => {
const version = req.header('API-Version') || 'v1'
if (version === 'v2') {
return handlerV2(req, res)
}
return handlerV1(req, res)
})
// Breaking changes require new version
// Multiple versions to maintain
GraphQL: Schema Evolution:
# No versioning needed - schema evolution
type User {
id: ID!
name: String!
email: String!
# Deprecated field - kept for backwards compatibility
username: String! @deprecated(reason: "Use email instead")
# New field - doesn't break existing clients
displayName: String
}
# Clients still using 'username' work fine
# New clients can use 'displayName'
# No API versioning needed
1. Resource Expansion:
// Allow clients to request related resources
GET /api/users/123?expand=posts,posts.comments
app.get('/api/users/:id', async (req, res) => {
const expand = req.query.expand?.split(',') || []
let query = db.users.query().findById(req.params.id)
if (expand.includes('posts')) {
query = query.withGraphFetched('posts')
}
if (expand.includes('posts.comments')) {
query = query.withGraphFetched('posts.comments')
}
const user = await query
res.json(user)
})
2. Field Selection:
// Allow clients to select specific fields
GET /api/users/123?fields=id,name,email
app.get('/api/users/:id', async (req, res) => {
const fields = req.query.fields?.split(',') || ['*']
const user = await db.users
.select(fields)
.where('id', req.params.id)
.first()
res.json(user)
})
3. Response Pagination:
// Cursor-based pagination
GET /api/posts?limit=20&cursor=eyJpZCI6MTIzfQ
app.get('/api/posts', async (req, res) => {
const limit = parseInt(req.query.limit) || 20
const cursor = decodeCursor(req.query.cursor)
const posts = await db.posts
.where('id', '>', cursor?.id || 0)
.limit(limit + 1)
.orderBy('id')
const hasMore = posts.length > limit
if (hasMore) posts.pop()
res.json({
data: posts,
pageInfo: {
hasNextPage: hasMore,
endCursor: encodeCursor(posts[posts.length - 1])
}
})
})
1. DataLoader (N+1 Prevention):
// Without DataLoader: N+1 queries
const resolvers = {
Post: {
author: async (post) => {
// Called once per post - N queries!
return db.users.findById(post.authorId)
}
}
}
// With DataLoader: Batched queries
import DataLoader from 'dataloader'
const userLoader = new DataLoader(async (userIds) => {
// Batched: single query for all users
const users = await db.users.findByIds(userIds)
// Return in same order as requested
return userIds.map(id =>
users.find(user => user.id === id)
)
})
const resolvers = {
Post: {
author: (post, _, { loaders }) => {
// Automatically batched and cached
return loaders.user.load(post.authorId)
}
}
}
2. Query Complexity Limits:
// Prevent expensive queries
import { createComplexityLimitRule } from 'graphql-validation-complexity'
const server = new ApolloServer({
typeDefs,
resolvers,
validationRules: [
createComplexityLimitRule(1000, {
onCost: (cost) => {
console.log('Query cost:', cost)
},
createError: (cost, maxCost) => {
return new GraphQLError(
`Query is too complex: ${cost} exceeds maximum ${maxCost}`
)
}
})
]
})
// Query cost calculation
type User {
id: ID! # cost: 1
posts: [Post!] # cost: 10 (list multiplication)
}
type Post {
id: ID! # cost: 1
comments: [Comment!] # cost: 10
}
# This query has cost: 1 + (10 * (1 + (10 * 1))) = 111
query {
user(id: "123") { # cost: 1
posts { # cost: 10 * ...
comments { # cost: 10 * 1
text
}
}
}
}
3. Persisted Queries:
// Client sends query hash instead of full query
const QUERY_HASH = 'abc123...'
// Client request
fetch('/graphql', {
method: 'POST',
body: JSON.stringify({
extensions: {
persistedQuery: {
version: 1,
sha256Hash: QUERY_HASH
}
},
variables: { userId: '123' }
})
})
// Server
const server = new ApolloServer({
typeDefs,
resolvers,
persistedQueries: {
cache: new Map(), // or Redis
}
})
// Benefits:
// - Smaller request size
// - CDN caching possible
// - Query allowlisting
✅ Public APIs for Third-Party Integration
// Example: Payment gateway, mapping service
// Reason: Standard, well-understood, great caching
GET /api/v1/payments/:id
POST /api/v1/payments
✅ Simple CRUD Operations
// Example: Basic blog, simple admin panels
// Reason: Straightforward, minimal overhead
GET /api/posts
POST /api/posts
PUT /api/posts/:id
DELETE /api/posts/:id
✅ File Uploads/Downloads
// Example: Document management, media uploads
// Reason: Better HTTP support for binary data
POST /api/files (multipart/form-data)
GET /api/files/:id/download
✅ Heavy CDN/HTTP Caching Requirements
// Example: Content delivery, static data
// Reason: Standard HTTP caching works perfectly
GET /api/articles/:slug
Cache-Control: public, max-age=3600
✅ Mobile Apps with Limited Bandwidth
# Example: Social media app
# Reason: Precise data fetching reduces bandwidth
query MobileTimeline {
posts(first: 20) {
id
thumbnail # Small image, not full resolution
caption # Not full content
likeCount # Not full list of likers
}
}
✅ Complex Data Relationships
# Example: E-commerce platform
# Reason: Fetch related data in one request
query ProductDetail {
product(id: "123") {
name
price
reviews(first: 5) {
rating
comment
user {
name
}
}
relatedProducts(limit: 4) {
name
price
image
}
seller {
name
rating
responseTime
}
}
}
✅ Real-Time Features
# Example: Chat application, live dashboard
# Reason: Built-in subscription support
subscription {
messageAdded(chatId: "123") {
id
text
sender {
name
avatar
}
}
}
✅ Multiple Client Types (Web, Mobile, Desktop)
# Example: SaaS platform
# Reason: Each client requests exactly what it needs
# Mobile query (minimal data)
query MobileDashboard {
user {
name
notificationCount
}
}
# Desktop query (rich data)
query DesktopDashboard {
user {
name
email
avatar
recentActivity {
timestamp
description
}
analytics {
pageViews
conversions
}
}
}
// GraphQL gateway wraps REST microservices
const typeDefs = gql`
type User {
id: ID!
name: String!
orders: [Order!]!
}
type Order {
id: ID!
total: Float!
items: [OrderItem!]!
}
`
const resolvers = {
Query: {
user: async (_, { id }) => {
// REST call to user service
return fetch(`${USER_SERVICE}/api/users/${id}`)
.then(res => res.json())
}
},
User: {
orders: async (user) => {
// REST call to order service
return fetch(`${ORDER_SERVICE}/api/users/${user.id}/orders`)
.then(res => res.json())
}
}
}
// Clients use GraphQL, backend services use REST
// Write operations via REST (simpler, better HTTP caching)
POST /api/posts // Create post
PUT /api/posts/:id // Update post
DELETE /api/posts/:id // Delete post
// Read operations via GraphQL (flexible querying)
query {
post(id: "123") {
title
author {
name
avatar
}
comments(first: 10) {
text
user {
name
}
}
}
}
// External API: REST (standard, documented)
// Public developers use this
GET /api/v1/users/:id
// Internal API: GraphQL (flexible, efficient)
// Our web/mobile apps use this
query {
user(id: "123") {
# Exactly what we need
}
}
API Documentation:
Testing:
Code Generation:
API Documentation:
Testing:
Code Generation:
Performance:
REST Security:
// Rate limiting per endpoint
const limiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 100
})
app.use('/api/users', limiter)
// CORS configuration
app.use(cors({
origin: ['https://myapp.com'],
credentials: true
}))
// API key authentication
app.use('/api', authenticateApiKey)
GraphQL Security:
// Query depth limiting
import depthLimit from 'graphql-depth-limit'
const server = new ApolloServer({
typeDefs,
resolvers,
validationRules: [depthLimit(5)]
})
// Query cost analysis
// Field-level authorization
const resolvers = {
User: {
email: (user, _, { user: currentUser }) => {
if (user.id !== currentUser.id && !currentUser.isAdmin) {
throw new ForbiddenError('Cannot access email')
}
return user.email
}
}
}
// Persisted queries (allowlist)
const server = new ApolloServer({
persistedQueries: {
cache: persistedQueryCache
},
allowedQueries: ['hash1', 'hash2'] // Only allow specific queries
})
REST Monitoring:
// APM tools work naturally
app.use((req, res, next) => {
const start = Date.now()
res.on('finish', () => {
metrics.recordHttpRequest({
method: req.method,
path: req.path,
status: res.statusCode,
duration: Date.now() - start
})
})
next()
})
GraphQL Monitoring:
// Apollo Studio integration
const server = new ApolloServer({
typeDefs,
resolvers,
plugins: [
ApolloServerPluginUsageReporting({
sendVariableValues: { all: true },
sendHeaders: { all: true }
})
]
})
// Custom operation tracking
const server = new ApolloServer({
plugins: [{
requestDidStart() {
return {
didResolveOperation(requestContext) {
metrics.recordOperation({
operationName: requestContext.operationName,
complexity: calculateComplexity(requestContext.document)
})
}
}
}
}]
})
// Phase 1: Introduce GraphQL alongside REST
app.use('/api/v1', restRouter)
app.use('/graphql', graphqlServer)
// Phase 2: Wrap REST endpoints in GraphQL resolvers
const resolvers = {
Query: {
user: (_, { id }) => fetch(`/api/v1/users/${id}`).then(r => r.json())
}
}
// Phase 3: Migrate clients gradually
// Web app uses GraphQL
// Mobile app still uses REST (until updated)
// Phase 4: Deprecate REST endpoints
// Monitor usage, remove when safe
In 2026, the GraphQL vs REST choice isn't binary - it's contextual. REST excels at simple, cacheable, public APIs with standard HTTP patterns. GraphQL shines for complex data relationships, multiple client types, and applications requiring precise data fetching.
Quick Decision Guide:
The best architecture often combines both technologies, leveraging each where it provides the most value.
At Cortara Labs, we design API architectures tailored to your specific requirements, whether that's REST, GraphQL, or a hybrid approach. We focus on performance, developer experience, and long-term maintainability.
Whether you're building a new API or modernizing an existing one, we can help you make the right architectural decisions. Contact us for a technical consultation, or explore our services to see how we build scalable APIs.
Follow @cortaralabs for more insights on API design and modern backend development.