REST and GraphQL are both valid API paradigms, but they solve different problems. The common mistake is picking one based on hype or familiarity rather than evaluating the specific needs of your clients and data model. This article covers when each makes sense, how to design each well, and the key differences that should drive your decision.
The Core Tradeoff
REST structures your API around resources. A resource is a URL (/users/42) that responds to a fixed set of HTTP methods. The response shape is defined by the server. Clients get what the endpoint returns.
GraphQL structures your API around a schema. Clients send queries specifying exactly which fields they want. The shape of the response is determined by the query, not the server. One endpoint handles all queries and mutations.
The practical implications:
| Concern | REST | GraphQL |
|---|---|---|
| Overfetching | Clients get all fields, even unused ones | Clients request only what they need |
| Underfetching | Multiple requests for related data | Single query fetches nested data |
| Caching | HTTP cache headers work naturally | Requires client-side cache (Apollo, etc.) |
| Type safety | Relies on OpenAPI or documentation | Schema-enforced, introspectable |
| Learning curve | Low — every developer knows HTTP | Higher — schema, resolvers, N+1 issues |
| Tooling maturity | Excellent | Strong but more complex |
| Good fit | Mobile apps with varied screen sizes, public APIs, simple CRUD | Complex UIs with many data relationships, internal APIs, rapid product iteration |
REST: Designing for Clarity
A well-designed REST API is predictable. Every endpoint follows the same conventions, every response has the same shape, and errors are handled consistently.
Resource Modeling
Resources are nouns. The HTTP method carries the action. Nest sub-resources when the child’s identity depends on the parent:
GET /users → list users (paginated)
POST /users → create user
GET /users/:id → get user
PUT /users/:id → replace user
PATCH /users/:id → partial update
DELETE /users/:id → delete user
GET /users/:id/posts → user's posts (nested resource)
POST /users/:id/posts → create post for user
Avoid deep nesting beyond two levels — /users/:id/posts/:pid/comments works but GET /comments?userId=X&postId=Y is often cleaner.
For actions that don’t map to CRUD, use sub-resource paths rather than verbs:
POST /users/:id/activate → activate account
POST /users/:id/password → change password (not PATCH /users/:id with partial body)
POST /sessions → login (creates a session)
DELETE /sessions/current → logout (deletes current session)
Consistent Response Envelope
A response envelope makes client code simpler — it can always check success before reading data:
// src/lib/respond.js
const ok = (res, data, meta = null) =>
res.status(200).json({ success: true, data, ...(meta && { meta }) });
const created = (res, data) =>
res.status(201).json({ success: true, data });
const fail = (res, message, { status = 400, code = null } = {}) =>
res.status(status).json({ success: false, error: { message, ...(code && { code }) } });
module.exports = { ok, created, fail };
Paginated list response:
{
"success": true,
"data": [{ "id": 1, "name": "Alice" }],
"meta": {
"page": 2, "limit": 20, "total": 183, "totalPages": 10
}
}
Filtering, Sorting, and Pagination
Expose these as query parameters with sensible defaults:
// GET /users?role=admin&sort=createdAt&order=desc&page=2&limit=20
const listUsers = async (req, res, next) => {
try {
const page = Math.max(1, parseInt(req.query.page, 10) || 1);
const limit = Math.min(100, parseInt(req.query.limit, 10) || 20);
const sort = req.query.sort || 'createdAt';
const order = req.query.order === 'asc' ? 1 : -1;
const filter = {};
if (req.query.role) filter.role = req.query.role;
const [total, users] = await Promise.all([
User.countDocuments(filter),
User.find(filter)
.sort({ [sort]: order })
.skip((page - 1) * limit)
.limit(limit)
.lean()
]);
return ok(res, users, {
page, limit, total, totalPages: Math.ceil(total / limit)
});
} catch (err) { next(err); }
};
Versioning
Version your REST API in the URL path. It’s explicit, easy to route, and allows you to run v1 and v2 simultaneously during migration periods:
const v1 = require('./routes/v1');
const v2 = require('./routes/v2');
app.use('/api/v1', v1);
app.use('/api/v2', v2);
Signal deprecation via headers so clients have advance warning:
const deprecate = (req, res, next) => {
res.set('Deprecation', 'true');
res.set('Sunset', 'Fri, 01 Jan 2027 00:00:00 GMT');
res.set('Link', '</api/v2>; rel="successor-version"');
next();
};
app.use('/api/v1', deprecate, v1);
GraphQL: When Flexibility Matters
GraphQL’s schema is a contract between server and clients. Every type, field, and relationship is declared upfront. Clients explore it with introspection queries, and every query is validated against the schema before execution.
Schema Design
Define types to model your domain. Use ! (non-null) deliberately — don’t mark everything non-null or you’ll need to handle nulls from the wrong direction:
# schema.graphql
type User {
id: ID!
name: String!
email: String!
role: UserRole!
posts: [Post!]!
createdAt: String!
}
type Post {
id: ID!
title: String!
content: String!
author: User!
publishedAt: String
}
enum UserRole { ADMIN USER MODERATOR }
type Query {
users(role: UserRole, page: Int, limit: Int): UserList!
user(id: ID!): User
post(id: ID!): Post
}
type UserList {
items: [User!]!
total: Int!
page: Int!
}
type Mutation {
createUser(input: CreateUserInput!): User!
updateUser(id: ID!, input: UpdateUserInput!): User!
deleteUser(id: ID!): Boolean!
}
input CreateUserInput {
name: String!
email: String!
password: String!
role: UserRole
}
input UpdateUserInput {
name: String
email: String
}
Use input types for mutation arguments — it keeps the schema clean and makes validation consistent.
Resolvers with DataLoader
Resolvers fetch data for each field. The N+1 problem is the most common GraphQL performance issue: fetching a list of posts, then running a separate SELECT for each post’s author. DataLoader solves this by batching all author lookups into one query:
// src/graphql/loaders.js
const DataLoader = require('dataloader');
const User = require('../models/User');
// Receives an array of IDs, returns a matching array of users (in the same order)
const createUserLoader = () => new DataLoader(async (userIds) => {
const users = await User.find({ _id: { $in: userIds } }).lean();
const userMap = new Map(users.map(u => [u._id.toString(), u]));
return userIds.map(id => userMap.get(id) ?? null);
});
// Create a fresh loader per request to prevent cross-request cache sharing
const createLoaders = () => ({ userLoader: createUserLoader() });
module.exports = createLoaders;
// src/graphql/resolvers.js
const resolvers = {
Query: {
users: async (_, { role, page = 1, limit = 20 }, { dataSources }) => {
const filter = role ? { role } : {};
const [items, total] = await Promise.all([
User.find(filter).skip((page - 1) * limit).limit(limit).lean(),
User.countDocuments(filter)
]);
return { items, total, page };
},
user: (_, { id }) => User.findById(id).lean(),
},
Post: {
// Uses the DataLoader — all author fetches in this query are batched into one DB call
author: (post, _, { loaders }) => loaders.userLoader.load(post.author.toString()),
},
Mutation: {
createUser: async (_, { input }) => {
const user = await User.create(input);
return user.toObject();
},
deleteUser: async (_, { id }) => {
await User.findByIdAndDelete(id);
return true;
},
},
};
Connect to Apollo Server with request-scoped loaders:
const { ApolloServer } = require('@apollo/server');
const { expressMiddleware } = require('@apollo/server/express4');
const createLoaders = require('./graphql/loaders');
const server = new ApolloServer({ typeDefs, resolvers });
await server.start();
app.use('/graphql', expressMiddleware(server, {
context: async ({ req }) => ({
user: req.user, // Auth middleware sets this
loaders: createLoaders(), // Fresh loader instance per request
})
}));
REST vs GraphQL: When to Choose Each
The decision comes down to your clients and your data relationships.
Choose REST when:
- You have a public API with external consumers who need stability and predictability
- Your data model is mostly independent resources with few cross-entity queries
- HTTP caching is important (REST caches at the CDN level naturally)
- Your team is small or the API surface is simple
Choose GraphQL when:
- You have multiple clients (web, mobile, third-party) with different data needs — they can each request exactly the fields they use
- Your domain has complex nested relationships that require multiple REST round-trips
- You’re building an internal API where schema introspection and type safety improve developer velocity
- You have enough backend complexity to justify learning DataLoader, persisted queries, and caching strategy
Many production systems use both: a public REST API for external partners and a GraphQL API for the internal frontend. The REST surface is stable and documented; the GraphQL surface is flexible and evolves quickly.
Summary
API design quality directly affects developer experience and system performance. The key principles regardless of paradigm:
- Model your domain clearly before writing any code — correct resource modeling in REST and schema design in GraphQL saves significant refactoring later
- Keep response shapes consistent — clients should have a single pattern to handle, not endpoint-specific special cases
- Version from the start; retrofitting versioning is painful
- In GraphQL, always use DataLoader for any field that resolves related entities — N+1 queries will degrade performance at scale
- Document with OpenAPI (REST) or rely on schema introspection (GraphQL) — undocumented APIs get misused
Resources
- RESTful API Design Guide (Google)
- GraphQL documentation
- Apollo Server documentation
- DataLoader
- OpenAPI 3.0 specification
Comments