When to Choose REST Over GraphQL
GraphQL has taken the API world by storm since its public release by Facebook in 2015. With its flexible querying, single endpoint architecture, and strong typing system, it's easy to assume that GraphQL is a universal replacement for REST. However, the reality is far more nuanced. REST (Representational State Transfer) remains the dominant API paradigm for a reason — it excels in specific scenarios where GraphQL introduces unnecessary complexity or friction. This tutorial explores when REST is the better choice and how to implement it effectively.
What Is REST?
REST is an architectural style for designing networked applications. It relies on stateless communication, standard HTTP methods (GET, POST, PUT, PATCH, DELETE), and resource-based URLs. Unlike GraphQL, which exposes a single endpoint that accepts complex queries, REST maps each resource to a distinct endpoint and uses HTTP semantics to define operations.
A typical REST API might expose endpoints like:
GET /api/users # List users
GET /api/users/123 # Get a specific user
POST /api/users # Create a user
PUT /api/users/123 # Update a user
DELETE /api/users/123 # Delete a user
GET /api/users/123/posts # Get posts by a user
Each endpoint returns a predictable shape of data, and the HTTP method clearly communicates the intent of the request.
Why This Decision Matters
Choosing the wrong API paradigm can have long-lasting consequences for your project. GraphQL introduces overhead in terms of server-side query parsing, resolver complexity, caching strategy changes, and client-side learning curves. REST, on the other hand, can lead to over-fetching or under-fetching when dealing with highly relational data needs. Understanding the trade-offs ensures you pick the right tool for your specific use case rather than following hype.
The decision impacts:
- Performance: Caching strategies differ significantly between REST and GraphQL.
- Scalability: REST integrates naturally with CDNs and HTTP infrastructure.
- Developer experience: REST is universally understood; GraphQL requires additional tooling.
- Security: REST's resource-based model maps cleanly to authorization rules.
- Maintenance: GraphQL schemas can become complex to manage over time.
When REST Is the Better Choice
1. Simple, Resource-Oriented APIs
If your API primarily exposes CRUD operations on well-defined resources, REST is the natural fit. There's no need for the query flexibility of GraphQL when clients simply need to list, create, update, or delete records.
# Express.js REST controller example
const express = require('express');
const router = express.Router();
// GET all products
router.get('/products', async (req, res) => {
const products = await Product.find();
res.json(products);
});
// GET a single product
router.get('/products/:id', async (req, res) => {
const product = await Product.findById(req.params.id);
if (!product) return res.status(404).json({ error: 'Not found' });
res.json(product);
});
// POST a new product
router.post('/products', async (req, res) => {
const product = new Product(req.body);
await product.save();
res.status(201).json(product);
});
module.exports = router;
2. When HTTP Caching Is Critical
REST leverages HTTP caching mechanisms out of the box. Responses can include Cache-Control, ETag, and Last-Modified headers that browsers, CDNs, and reverse proxies understand natively. GraphQL, by contrast, typically uses POST requests with complex query bodies, making HTTP-level caching impractical.
# Setting cache headers in Express
router.get('/products/:id', async (req, res) => {
const product = await Product.findById(req.params.id);
if (!product) return res.status(404).json({ error: 'Not found' });
res.set('Cache-Control', 'public, max-age=3600');
res.set('ETag', `"${product._rev}"`);
res.json(product);
});
With this setup, a CDN like Cloudflare or Fastly can cache responses automatically, dramatically reducing server load for read-heavy applications.
3. Public APIs and Third-Party Integrations
When building a public API consumed by external developers, REST's predictability is a major advantage. Most developers already understand REST conventions, and tools like Swagger/OpenAPI make documentation straightforward. GraphQL requires clients to learn your schema, use specialized clients, and understand query syntax.
# OpenAPI specification snippet
openapi: 3.0.0
info:
title: Product API
version: 1.0.0
paths:
/products:
get:
summary: List all products
responses:
'200':
description: A list of products
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/Product'
4. When You Need Fine-Grained Authorization
REST maps authorization rules cleanly to endpoints and HTTP methods. You can easily say "only admins can DELETE /products" or "users can only GET their own orders." GraphQL's single endpoint makes this harder because authorization must be handled at the resolver or field level, which can become error-prone as schemas grow.
# Role-based middleware in Express
function requireRole(role) {
return (req, res, next) => {
if (req.user.role !== role) {
return res.status(403).json({ error: 'Forbidden' });
}
next();
};
}
router.delete('/products/:id', requireRole('admin'), async (req, res) => {
await Product.findByIdAndDelete(req.params.id);
res.status(204).send();
});
5. Server-to-Server Communication
For microservices communicating with each other, REST is often the simpler and more reliable choice. The overhead of setting up a GraphQL gateway between services rarely pays off when the data requirements are known and stable. REST's statelessness and HTTP semantics align well with service mesh patterns and load balancers.
6. File Uploads and Binary Data
REST handles file uploads cleanly with multipart/form-data. While GraphQL has multipart upload specifications, they are add-ons rather than first-class citizens. For media-heavy applications, REST endpoints dedicated to file handling are more straightforward.
# File upload endpoint in Express
const multer = require('multer');
const upload = multer({ dest: 'uploads/' });
router.post('/uploads', upload.single('file'), (req, res) => {
if (!req.file) return res.status(400).json({ error: 'No file provided' });
res.status(201).json({
filename: req.file.filename,
size: req.file.size,
url: `/uploads/${req.file.filename}`
});
});
How to Implement REST Effectively
Design Resources, Not Actions
Think in terms of nouns, not verbs. Use /orders rather than /createOrder. The HTTP method defines the action.
Use Proper Status Codes
200 OK # Successful GET, PUT, PATCH
201 Created # Successful POST
204 No Content # Successful DELETE
400 Bad Request # Validation error
401 Unauthorized # Not authenticated
403 Forbidden # Authenticated but not allowed
404 Not Found # Resource doesn't exist
500 Server Error # Internal failure
Support Pagination, Filtering, and Sorting
# Paginated, filtered, sorted list endpoint
router.get('/products', async (req, res) => {
const { page = 1, limit = 20, category, sort } = req.query;
const query = {};
if (category) query.category = category;
const sortOption = sort === 'price_desc' ? { price: -1 } : { price: 1 };
const products = await Product.find(query)
.sort(sortOption)
.skip((page - 1) * limit)
.limit(parseInt(limit));
const total = await Product.countDocuments(query);
res.json({
data: products,
pagination: {
page: parseInt(page),
limit: parseInt(limit),
total,
totalPages: Math.ceil(total / limit)
}
});
});
Version Your API
# Versioned routes
app.use('/api/v1', v1Router);
app.use('/api/v2', v2Router);
Best Practices
- Keep payloads lean: Avoid returning unnecessary fields. If clients need different shapes, use sparse fieldsets or dedicated endpoints.
- Use consistent naming: Stick to plural nouns for collections (
/users,/orders) and kebab-case for multi-word resources (/order-items). - Handle errors consistently: Return a standardized error format across all endpoints.
- Document with OpenAPI: Generate interactive docs with Swagger UI or Redoc so consumers can explore your API easily.
- Use HATEOAS selectively: Include links to related resources when it adds value, but don't over-engineer hypermedia controls for internal APIs.
- Rate limit your endpoints: Protect your API from abuse with tools like
express-rate-limit. - Log and monitor: Track response times, error rates, and endpoint usage to identify bottlenecks early.
# Consistent error response format
function errorHandler(err, req, res, next) {
const status = err.status || 500;
res.status(status).json({
error: {
code: err.code || 'INTERNAL_ERROR',
message: err.message || 'Something went wrong',
details: err.details || null
}
});
}
app.use(errorHandler);
When GraphQL Might Still Be Better
To be fair, GraphQL shines when you have deeply nested data relationships, multiple client types with different data needs (e.g., mobile vs. web), or a rapidly evolving frontend that requires frequent changes to data shapes. If your team spends significant time building and maintaining multiple REST endpoints just to serve slightly different views, GraphQL's flexibility may justify the added complexity.
Conclusion
REST and GraphQL are not competitors in a zero-sum game — they are tools with different strengths. REST remains the superior choice for resource-oriented APIs, public integrations, caching-heavy applications, fine-grained authorization, file uploads, and server-to-server communication. By understanding your specific requirements around caching, client diversity, data complexity, and team expertise, you can make an informed decision that serves your project for years to come. Choose REST when simplicity, predictability, and HTTP-native behavior matter most; reach for GraphQL when query flexibility and client-driven data fetching are worth the trade-offs.