GraphQL vs REST: Choosing the Right API Architecture
A practical comparison of GraphQL and REST API architectures covering over-fetching, type safety, caching, and when to choose each approach.
Tags
GraphQL vs REST: Choosing the Right API Architecture
GraphQL and REST are two fundamentally different approaches to API design, and the right choice depends on your specific use case rather than industry trends. REST organizes your API around resources with predictable HTTP endpoints, leveraging the full HTTP protocol for caching, status codes, and method semantics. GraphQL provides a typed query language that lets clients request exactly the data they need from a single endpoint. Neither is universally superior—each excels in different scenarios.
TL;DR
Choose REST when you have straightforward resource-based operations, need HTTP-level caching, or serve a limited number of clients with predictable data needs. Choose GraphQL when you have multiple clients needing different data shapes, complex relationships between entities, or when over-fetching and under-fetching are measurable problems. Many production systems use both, leveraging each where it fits best.
Why This Matters
API architecture is one of the most consequential technical decisions in a project because it affects every team that consumes the API—frontend, mobile, third-party integrators—for the lifetime of the system. Switching from REST to GraphQL (or vice versa) after launch is expensive. Understanding the trade-offs upfront lets you make a decision that scales with your product instead of fighting against it.
How It Works
The Over-Fetching and Under-Fetching Problem
The most commonly cited motivation for GraphQL is the over-fetching/under-fetching problem inherent in REST.
Consider a social media application where you need to display a user profile with their recent posts and follower count. With REST:
// REST: Multiple requests, over-fetching on each
const user = await fetch('/api/users/123');
// Returns: { id, name, email, bio, avatar, createdAt, settings, ... }
// You only need: name, avatar
const posts = await fetch('/api/users/123/posts');
// Returns: [{ id, title, body, tags, metadata, comments, ... }]
// You only need: title, createdAt
const followers = await fetch('/api/users/123/followers?count=true');
// Returns: { count: 1420, followers: [...] }
// You only need: countThree requests, each returning more data than needed. With GraphQL:
// GraphQL: Single request, exact data
const query = `
query UserProfile($id: ID!) {
user(id: $id) {
name
avatar
posts(first: 5) {
title
createdAt
}
followersCount
}
}
`;
const { data } = await client.query({ query, variables: { id: '123' } });
// Returns exactly: { name, avatar, posts: [{ title, createdAt }], followersCount }One request. No wasted bytes. No wasted round trips.
Schema-First Design and Type Safety
GraphQL APIs are defined by a schema that serves as a contract between client and server:
// schema.graphql
type User {
id: ID!
name: String!
email: String!
avatar: String
posts(first: Int, after: String): PostConnection!
followersCount: Int!
}
type Post {
id: ID!
title: String!
body: String!
author: User!
tags: [String!]!
createdAt: DateTime!
comments(first: Int): [Comment!]!
}
type PostConnection {
edges: [PostEdge!]!
pageInfo: PageInfo!
}
type Query {
user(id: ID!): User
posts(filter: PostFilter): PostConnection!
}
type Mutation {
createPost(input: CreatePostInput!): Post!
updatePost(id: ID!, input: UpdatePostInput!): Post!
}This schema is introspectable—tools can auto-generate TypeScript types, API documentation, and client code. REST achieves something similar with OpenAPI/Swagger, but it is an add-on rather than a foundational requirement.
REST: Resource-Oriented Simplicity
REST's strength lies in its alignment with HTTP. Each resource has a URL. HTTP methods map to operations. Status codes communicate outcomes. Caching headers control freshness. This alignment makes REST APIs predictable and cacheable by default:
// REST API with Express
import express from 'express';
const app = express();
// GET /api/posts — list posts
app.get('/api/posts', async (req, res) => {
const { page = 1, limit = 20, tag } = req.query;
const posts = await PostService.find({ tag, page, limit });
res.set('Cache-Control', 'public, max-age=60, stale-while-revalidate=300');
res.json({
data: posts.items,
pagination: {
page: posts.page,
totalPages: posts.totalPages,
total: posts.total,
},
});
});
// GET /api/posts/:id — single post
app.get('/api/posts/:id', async (req, res) => {
const post = await PostService.findById(req.params.id);
if (!post) return res.status(404).json({ error: 'Post not found' });
res.set('Cache-Control', 'public, max-age=300');
res.set('ETag', post.version);
res.json({ data: post });
});
// POST /api/posts — create post
app.post('/api/posts', authenticate, async (req, res) => {
const post = await PostService.create(req.body, req.user.id);
res.status(201).location(`/api/posts/${post.id}`).json({ data: post });
});Every intermediary—CDNs, reverse proxies, browser caches—understands these semantics without additional tooling.
The N+1 Problem in GraphQL
GraphQL's flexibility comes with a performance trap. When a query requests related data, naive resolver implementations execute a separate database query for each parent item:
// Naive resolver — causes N+1 queries
const resolvers = {
Query: {
posts: () => db.posts.findMany(), // 1 query
},
Post: {
author: (post) => db.users.findById(post.authorId), // N queries
},
};
// Fetching 50 posts = 1 + 50 = 51 database queriesThe solution is DataLoader, which batches and deduplicates requests within a single tick of the event loop:
import DataLoader from 'dataloader';
// Create a batching loader
const userLoader = new DataLoader(async (userIds: readonly string[]) => {
const users = await db.users.findMany({
where: { id: { in: [...userIds] } },
});
// Return in the same order as the input IDs
const userMap = new Map(users.map((u) => [u.id, u]));
return userIds.map((id) => userMap.get(id) || null);
});
const resolvers = {
Query: {
posts: () => db.posts.findMany(), // 1 query
},
Post: {
author: (post) => userLoader.load(post.authorId), // Batched into 1 query
},
};
// Fetching 50 posts = 1 + 1 = 2 database queriesCaching Differences
This is where REST has a structural advantage. REST APIs naturally benefit from HTTP caching:
// REST: HTTP caching works out of the box
// CDN caches GET /api/posts/123 based on URL + headers
// Browser caches based on Cache-Control headers
// Reverse proxies (Varnish, Nginx) cache by URL pattern
// GraphQL: HTTP caching does not work by default
// All queries go to POST /graphql with different bodies
// CDNs cannot distinguish between queries
// Requires client-side normalized cachingGraphQL caching relies on client-side solutions like Apollo Client's normalized cache, which stores entities by type and ID and automatically updates the UI when mutations modify cached entities:
import { ApolloClient, InMemoryCache } from '@apollo/client';
const client = new ApolloClient({
uri: '/graphql',
cache: new InMemoryCache({
typePolicies: {
Post: {
keyFields: ['id'],
},
User: {
keyFields: ['id'],
},
},
}),
});For server-side GraphQL caching, persisted queries can help. By mapping queries to hashes, you can use GET requests with query IDs, making CDN caching possible:
// Persisted query: GET /graphql?extensions={"persistedQuery":{"sha256Hash":"abc123"}}
// This URL is cacheable by CDNsHybrid Approaches
Many production systems use both GraphQL and REST:
// REST for simple CRUD and file operations
app.post('/api/upload', multer().single('file'), uploadHandler);
app.get('/api/health', healthCheckHandler);
app.get('/api/exports/:id/download', fileDownloadHandler);
// GraphQL for complex data fetching
app.use('/graphql', graphqlMiddleware({
schema,
context: ({ req }) => ({
user: req.user,
loaders: createLoaders(),
}),
}));Another pattern is a GraphQL gateway that wraps existing REST microservices:
const resolvers = {
Query: {
product: async (_, { id }) => {
// GraphQL resolves from REST services
const product = await fetch(`http://product-service/api/products/${id}`);
const reviews = await fetch(`http://review-service/api/products/${id}/reviews`);
return { ...await product.json(), reviews: await reviews.json() };
},
},
};Practical Implementation
When evaluating which approach fits your project, consider these concrete factors:
Number of clients. A single web frontend with predictable data needs? REST is simpler. Multiple clients (web, iOS, Android, partner APIs) each needing different fields? GraphQL reduces the coordination overhead.
Data relationships. Flat resources with minimal nesting? REST maps naturally. Deeply nested graphs where clients traverse relationships? GraphQL avoids the cascade of REST requests.
Team experience. REST is familiar to virtually every backend developer. GraphQL requires learning schema design, resolver patterns, DataLoader, and client-side caching. Factor in the ramp-up time.
Caching requirements. If CDN caching is a primary performance strategy, REST is significantly easier to cache. GraphQL caching is possible but requires more infrastructure.
Common Pitfalls
Adopting GraphQL for simple CRUD APIs. If your API is straightforward create-read-update-delete on a few resource types, GraphQL adds complexity without meaningful benefit. REST handles this elegantly.
Ignoring the N+1 problem. Every GraphQL API without DataLoader (or equivalent batching) will hit performance problems as data grows. This is not optional—it is a requirement for production GraphQL.
Exposing your entire database schema via GraphQL. Your GraphQL schema should model your domain, not your database tables. Leaking implementation details through the API schema creates coupling that makes both sides harder to change.
Not rate-limiting GraphQL query complexity. A single GraphQL query can request arbitrarily deep nested data. Without query complexity analysis and depth limiting, a malicious or naive client can overload your server with a single request.
Treating REST API versioning as simple. URL versioning (/v1/, /v2/) is common but creates maintenance burden. Consider using content negotiation or additive changes to avoid breaking clients.
When to Use (and When Not To)
Use REST when:
- ›Your API is resource-oriented with straightforward CRUD operations
- ›HTTP caching at the CDN/proxy level is a critical performance requirement
- ›You serve a single client with predictable, stable data needs
- ›Your team is experienced with REST and new to GraphQL
- ›You need simple file uploads and downloads
- ›Third-party developers will consume your API (REST is more universally understood)
Use GraphQL when:
- ›Multiple clients (web, mobile, partners) need different data shapes from the same backend
- ›Your data model has complex relationships that clients need to traverse
- ›Over-fetching or under-fetching is a measurable performance problem
- ›You want strong typing and automatic documentation from the schema
- ›You need real-time subscriptions alongside request-response queries
- ›Your frontend team wants to iterate on data requirements without backend changes
Use both when:
- ›You have existing REST services and want to add a GraphQL aggregation layer
- ›Some operations (file handling, health checks, webhooks) fit REST better while data fetching fits GraphQL
- ›You are migrating incrementally from REST to GraphQL
FAQ
What is the main advantage of GraphQL over REST?
GraphQL eliminates over-fetching and under-fetching by letting clients request exactly the fields and relationships they need in a single query, reducing network payload and round trips.
When is REST better than GraphQL?
REST is better for simple resource-oriented APIs, when HTTP caching is critical, when serving a single client with predictable needs, for file operations, or when your team lacks GraphQL experience.
What is the N+1 problem in GraphQL?
It occurs when resolving a list of items where each item requires a separate database query for related data. DataLoader solves this by batching those individual queries into a single batch request.
Can you use GraphQL and REST together?
Yes. A common hybrid uses REST for simple CRUD and file handling while using GraphQL for complex data fetching. You can also place a GraphQL gateway in front of existing REST microservices.
How does caching differ between GraphQL and REST?
REST leverages HTTP caching natively via CDNs, browser caches, and reverse proxies keyed on URLs. GraphQL uses POST requests to a single endpoint, bypassing HTTP caching, and requires client-side normalized caches or persisted queries.
Collaboration
Need help with a project?
Let's Build It
I help startups and established companies design, build, and scale world-class digital products. From deep technical architecture to pixel-perfect UI — let's bring your vision to life.
Related Articles
How to Design API Contracts Between Micro-Frontends and BFFs
Learn how to design stable API contracts between Micro-Frontends and Backend-for-Frontend layers with versioning, ownership boundaries, error handling, and schema governance.
Next.js BFF Architecture
An architectural deep dive into using Next.js as a Backend-for-Frontend, including route handlers, server components, auth boundaries, caching, and service orchestration.
Next.js Cache Components and PPR in Real Apps
A practical guide to using Next.js Cache Components and Partial Prerendering in real applications, with tradeoffs, cache strategy, and freshness considerations.