GraphQL vs REST — When to Choose and How to Migrate
A decision guide comparing GraphQL and REST APIs: use cases, performance, caching, tooling, and migration strategies for engineering teams.
Note: This guide follows English-language naming conventions and terminology standards common in international development teams. Examples use English identifiers and comments to maximize compatibility across codebases and tooling.
Overview
The GraphQL vs REST debate is not about which is better — it is about which is appropriate for your constraints. REST is the default choice for most APIs because it is simple, cache-friendly, and universally supported. GraphQL shines when clients need flexible data fetching and your API surface is large and evolving. But GraphQL introduces complexity in caching, monitoring, and security that REST does not have. This guide gives you a decision framework, performance comparison, and migration path.
When to Use
- For alternatives, see Complete Guide to API Versioning Strategies.
Use this guide when:
- You are designing a new API and need to choose between GraphQL and REST
- Your REST API has grown to hundreds of endpoints and clients are making N+1 requests
- You are considering migrating from REST to GraphQL and need to understand the trade-offs
Solution
Decision Framework
| Criterion | REST Wins | GraphQL Wins |
|---|---|---|
| Client Diversity | Few clients with similar needs | Many clients (web, mobile, IoT) with different data needs |
| Data Fetching | Fixed, predictable responses | Clients specify exactly what they need |
| Caching | HTTP caching (CDN, browser) works out of the box | Requires custom caching (DataLoader, persisted queries) |
| File Uploads | Native multipart/form-data | Requires separate upload mechanism |
| Observability | One endpoint per resource; metrics are straightforward | Single endpoint; query complexity analysis required |
| Learning Curve | Universal knowledge; minimal tooling | New query language, resolvers, schema stitching |
| Versioning | URL versioning (/v1/, /v2/) | Schema evolution (deprecate fields) |
| Tooling | Mature: Swagger, Postman, curl | Growing: Apollo, Relay, GraphiQL |
REST API Example
# Client needs user + orders + products
GET /api/users/123
GET /api/users/123/orders
GET /api/products/456
GET /api/products/789
# 4 requests, over-fetching on user, under-fetching on orders
# REST implementation (Flask)
@app.route('/api/users/<id>')
def get_user(id):
user = db.query(User).get(id)
return jsonify(user.to_dict())
@app.route('/api/users/<id>/orders')
def get_user_orders(id):
orders = db.query(Order).filter_by(user_id=id).all()
return jsonify([o.to_dict() for o in orders])
GraphQL Example
# Single request, precise data
query GetUserWithOrders($userId: ID!) {
user(id: $userId) {
name
email
orders {
id
total
products {
name
price
}
}
}
}
# GraphQL resolver (Strawberry / Graphene style)
@strawberry.type
class User:
id: ID
name: str
email: str
@strawberry.field
def orders(self) -> List[Order]:
# N+1 risk here; use DataLoader
return order_loader.load(self.id)
@strawberry.type
class Query:
@strawberry.field
def user(self, id: ID) -> User:
return db.query(UserModel).get(id)
N+1 Problem and DataLoader
from promise import Promise
from promise.dataloader import DataLoader
class OrderLoader(DataLoader):
def batch_load_fn(self, user_ids):
# Single query for all users
orders = db.query(Order).filter(Order.user_id.in_(user_ids)).all()
orders_by_user = defaultdict(list)
for o in orders:
orders_by_user[o.user_id].append(o)
return Promise.resolve([orders_by_user[uid] for uid in user_ids])
order_loader = OrderLoader()
Query Complexity Analysis
# Apollo Server style
const { createComplexityLimitRule } = require('graphql-validation-complexity');
const MAX_COMPLEXITY = 1000;
const server = new ApolloServer({
typeDefs,
resolvers,
validationRules: [
createComplexityLimitRule(MAX_COMPLEXITY, {
onComplete: (complexity) => {
console.log(`Query complexity: ${complexity}`);
}
})
]
});
Explanation
The core difference is who controls the data shape. In REST, the server defines the response shape; clients get what the server sends. In GraphQL, the client defines the query shape; the server resolves only the requested fields. This flexibility is capable but dangerous: a malicious or poorly written client can request deeply nested data that causes database overload.
Caching is REST’s biggest advantage. HTTP caching (ETags, Cache-Control, CDNs) works because URLs are cache keys. GraphQL has a single URL and cache keys must be derived from the query body — which is harder for CDNs to handle. Persisted queries (where the client sends a query hash instead of the full query) restore some caching but add operational complexity.
N+1 queries are the silent killer of GraphQL performance. When a client requests user.orders.products, naive resolvers execute: 1 query for the user, N queries for orders, N*M queries for products. DataLoader solves this by batching and caching within a single request, but it requires discipline: every resolver that loads related data must use the loader.
Detailed Scenario: Gradual REST to GraphQL Migration
System: Blog platform with existing REST API
REST endpoints: /posts, /posts/:id, /users/:id, /comments, /tags
Problem: Mobile app makes 4-5 requests per screen (over-fetching)
Goal: Reduce to 1 request per screen with GraphQL
Phase 1: GraphQL aggregation layer over REST (week 1-2)
- Install Apollo Server as proxy
- Each resolver calls existing REST endpoint
- No changes to backend services
const resolvers = {
Query: {
post: (_, { id }) => fetch(`https://api.example.com/posts/${id}`).then(r => r.json()),
posts: (_, { limit = 10 }) => fetch(`https://api.example.com/posts?limit=${limit}`).then(r => r.json()),
user: (_, { id }) => fetch(`https://api.example.com/users/${id}`).then(r => r.json()),
},
Post: {
author: (post) => fetch(`https://api.example.com/users/${post.authorId}`).then(r => r.json()),
comments: (post) => fetch(`https://api.example.com/posts/${post.id}/comments`).then(r => r.json()),
}
}
Result: Mobile app makes 1 GraphQL query instead of 4-5 REST requests
Latency: Similar (proxy adds overhead, but reduces round-trips)
Phase 2: DataLoader for N+1 (week 3)
- Without DataLoader: query of 10 posts with authors = 11 REST requests
- With DataLoader: 2 REST requests (posts + batch of authors)
const authorLoader = new DataLoader(async (authorIds) => {
const users = await fetch(`https://api.example.com/users?ids=${authorIds.join(",")}`).then(r => r.json());
return authorIds.map(id => users.find(u => u.id === id));
});
Post: {
author: (post) => authorLoader.load(post.authorId)
}
Phase 3: Migrate resolvers to direct database (month 2-3)
- Replace REST fetch with direct SQL queries
- Measure latency before/after each migration
- Keep REST for services that already have good latency
Phase 4: Persisted queries in production (month 4)
- Precompile queries at build time
- Client sends hash instead of query string
- Reduce request payload by 60-80%
// Client
const query = gql`query GetPost($id: ID!) { post(id: $id) { title author { name } } }`;
// In production: sends only hash "abc123"
Metrics before/after:
| Metric | REST | GraphQL (phase 4) |
|---------|------|-------------------|
| Requests per screen (mobile) | 4-5 | 1 |
| Request payload | 200-500 bytes | 8 bytes (hash) |
| p95 latency | 350ms | 180ms |
| Data transferred per screen | 8KB | 2KB |
How do I handle caching in GraphQL?
GraphQL does not have native HTTP caching like REST. Use Apollo Client cache (normalized cache on the client) to avoid refetch. On the server, cache resolvers with Redis or Memcached. Configure cacheControl in your resolvers so Apollo Server generates HTTP headers for CDN caching of responses. For immutable data, use maxAge: 3600. For volatile data, use maxAge: 0.
How do I prevent malicious query attacks in GraphQL?
Configure three protections: (1) maxDepth to limit query depth (e.g., 7 levels), (2) complexity score to limit total query cost, (3) rate limiting based on cost, not request count. A complex query counts more than a simple one. Use graphql-cost-analysis or graphql-query-complexity to calculate cost. Block queries that exceed the limit before executing them.
Variants
| Architecture | REST Approach | GraphQL Approach |
|---|---|---|
| Mobile API | Over-fetching wastes bandwidth; multiple requests drain battery | Single request with minimal payload; perfect for slow networks |
| Public API | Easy to document with OpenAPI; curl examples work | Steeper learning curve; but self-documenting schema |
| Internal Microservices | gRPC or REST for service-to-service | GraphQL federation for aggregating multiple services |
| Real-time Data | Polling or WebSockets alongside REST | Subscriptions built into the protocol |
| Legacy Migration | Incremental endpoint additions | Wrap REST with GraphQL resolvers (strangler fig pattern) |
What Works
- Start with REST unless you have a specific GraphQL use case; premature GraphQL is expensive
- Use persisted queries in production to enable caching and prevent malicious queries
- Implement query complexity limits before shipping GraphQL to untrusted clients
- Monitor resolver execution time individually; slow resolvers are hidden behind a single endpoint
- Version GraphQL via schema evolution, not URLs; deprecate fields with
@deprecateddirective
Common Mistakes
- Migrating to GraphQL for the wrong reason — “REST has too many endpoints” is not a problem if caching works
- Not using DataLoader — N+1 queries will kill your database under load
- Exposing internal database models directly as GraphQL types; always have a transformation layer
- Not analyzing query complexity — a single malicious query can DoS your API
- Mixing REST and GraphQL on the same endpoint; it creates confusion and tooling problems
Troubleshooting
- High latency between services: trace the request path. Look for synchronous chains, missing caching, and oversized payloads that cross network boundaries.
- Single point of failure: identify components without redundancy. Add replicas, failover, or circuit breakers before scaling traffic.
- Unexpected coupling between services: review shared databases, libraries, and schemas. Bound contexts should own their data and expose stable interfaces.
- Cost spikes after scaling: right-size instances and use autoscaling with limits. Reserved capacity or spot instances can reduce steady-state spend.
- Difficult to reason about the system: maintain architecture decision records and service dependency maps. Use observability to validate the diagrams.
FAQ
Can I use both REST and GraphQL in the same project?
Yes, and many organizations do. A common pattern is: REST for public/external APIs (cache-friendly, familiar) and GraphQL for internal/mobile applications (flexible data fetching). Another pattern is using GraphQL as an aggregation layer: internal services expose REST/gRPC, and a GraphQL gateway composes them for the frontend. Do not expose the same resource through both on the same domain without clear segmentation — it confuses consumers and splits your caching strategy.
How do I migrate from REST to GraphQL incrementally?
Use the strangler fig pattern: build a GraphQL layer in front of your existing REST API. Each GraphQL resolver calls your REST endpoints. Migrate endpoints one at a time to direct database resolvers as needed. This lets frontend teams start using GraphQL immediately without waiting for backend rewrites. Over time, replace REST calls with direct database access in resolvers. Do not rewrite the entire API at once — incremental migration reduces risk and allows learning.
Is GraphQL slower than REST?
It depends on the query. A simple GraphQL query is slightly slower than an equivalent REST call due to resolver overhead (1–5ms). A well-optimized GraphQL query that fetches nested data in a single request is faster than multiple REST calls (network latency dominates). A poorly written GraphQL query with N+1 problems is orders of magnitude slower than REST. The performance difference is not natural to the protocol — it is natural to implementation quality. GraphQL requires more engineering discipline to perform well.
End of document. Review and update quarterly.
Common Production Pitfalls
- Treating the guide as a checklist to complete once rather than a practice to evolve.
- Adopting every recommendation at once instead of starting with one measured change.
- Skipping the maturity assessment and forcing advanced practices on an unprepared team.
- Not updating runbooks and on-call expectations as new practices are introduced.
- Ignoring real incident data when prioritizing which parts of the guide to apply first.
- Failing to assign an owner who reviews decisions quarterly.
- Copying examples without adapting them to the team’s actual tooling and constraints.
- Forgetting to measure outcomes before adding the next improvement.
Related Resources
REST API Design Guide
A thorough guide to designing clean, scalable, and maintainable REST APIs.
GuideAPI Gateway Design: Resilience, Routing, and Security
A practical guide to designing API gateways: routing patterns, rate limiting, authentication, circuit breakers, and observability for resilient APIs.
DocAPI Performance Budget Template
A template for setting and tracking API latency and throughput performance budgets.