API Design Patterns: Architecture and Trade-offs of REST, GraphQL, and gRPC
Designing robust, scalable, and maintainable APIs is one of the most critical skills in modern development. Choosing the right architectural pattern can make the difference between a system that scales effortlessly and one that collapses under load.
In this in-depth guide, we’ll analyze the main API design patterns, comparing REST, GraphQL, and gRPC from an architectural standpoint, with a focus on performance, trade-offs, and real-world use cases.
REST: The Foundation of Web APIs
REST (Representational State Transfer) remains the most widely used pattern for public APIs and web-oriented services.
REST Architectural Principles
Client-Server Separation: A clean separation between client and server that allows them to evolve independently.
Stateless: Every request contains all the information needed. The server holds no state between requests.
Cacheable: Responses must explicitly indicate whether they can be cached.
Uniform Interface: A uniform interface based on resources identified by URIs.
Layered System: A layered architecture that allows intermediaries (caches, load balancers, gateways).
Advanced REST Design Patterns
1. Resource Modeling and URI Design
Resource design is fundamental. Follow these principles:
// ❌ Bad design - verbs in the URI
GET /getUsers
POST /createUser
GET /deleteUser?id=123
// ✅ Good design - nouns, semantic HTTP methods
GET /users // List users
POST /users // Create user
GET /users/{id} // User detail
PUT /users/{id} // Full update
PATCH /users/{id} // Partial update
DELETE /users/{id} // Delete user
// Nested resources for relationships
GET /users/{id}/orders
GET /users/{id}/orders/{orderId}
// Query parameters for filtering, sorting, pagination
GET /users?role=admin&sort=-createdAt&page=2&limit=20
2. HATEOAS (Hypermedia as the Engine of Application State)
Include links to available actions in the response:
// HATEOAS implementation in Express
app.get('/api/users/:id', async (req, res) => {
const user = await User.findById(req.params.id);
res.json({
data: {
id: user.id,
name: user.name,
email: user.email,
role: user.role
},
links: {
self: `/api/users/${user.id}`,
orders: `/api/users/${user.id}/orders`,
update: {
href: `/api/users/${user.id}`,
method: 'PATCH'
},
delete: {
href: `/api/users/${user.id}`,
method: 'DELETE'
}
}
});
});
Trade-off: HATEOAS increases payload size (~20-30%) but makes the API self-documenting and more evolvable.
3. API Versioning Strategies
There are four main approaches to versioning:
URI Versioning (most common):
GET /api/v1/users
GET /api/v2/users
// Implementation with Express
const v1Router = express.Router();
const v2Router = express.Router();
v1Router.get('/users', getUsersV1);
v2Router.get('/users', getUsersV2);
app.use('/api/v1', v1Router);
app.use('/api/v2', v2Router);
Header Versioning (cleaner):
// Client request
GET /api/users
Accept: application/vnd.company.v2+json
// Express middleware
app.use((req, res, next) => {
const acceptHeader = req.get('Accept') || '';
const versionMatch = acceptHeader.match(/\.v(\d+)\+/);
req.apiVersion = versionMatch ? parseInt(versionMatch[1]) : 1;
next();
});
app.get('/api/users', (req, res) => {
if (req.apiVersion === 2) {
return getUsersV2(req, res);
}
return getUsersV1(req, res);
});
Query Parameter Versioning:
GET /api/users?version=2
Content Negotiation:
Accept: application/vnd.company.user.v2+json
Recommendation: URI versioning for public APIs (client simplicity), Header versioning for internal APIs (correct HTTP semantics).
4. Pagination Pattern
Implement efficient pagination for large datasets:
// Cursor-based pagination (preferable for real-time data)
class PaginationService {
static async cursorPaginate(model, cursor, limit = 20) {
const query = cursor
? { createdAt: { $lt: new Date(cursor) } }
: {};
const items = await model
.find(query)
.sort({ createdAt: -1 })
.limit(limit + 1); // +1 to determine hasNext
const hasNext = items.length > limit;
const results = hasNext ? items.slice(0, -1) : items;
const nextCursor = hasNext
? results[results.length - 1].createdAt.toISOString()
: null;
return {
data: results,
pagination: {
nextCursor,
hasNext,
limit
}
};
}
// Offset-based pagination (simpler but less performant)
static async offsetPaginate(model, page = 1, limit = 20) {
const skip = (page - 1) * limit;
const [items, total] = await Promise.all([
model.find().skip(skip).limit(limit),
model.countDocuments()
]);
return {
data: items,
pagination: {
page,
limit,
total,
totalPages: Math.ceil(total / limit),
hasNext: page * limit < total,
hasPrev: page > 1
}
};
}
}
// Usage in the endpoint
app.get('/api/users', async (req, res) => {
const { cursor, limit } = req.query;
const result = await PaginationService.cursorPaginate(
User,
cursor,
parseInt(limit) || 20
);
res.json(result);
});
Performance Benchmark:
- Cursor-based: O(1) - constant regardless of position
- Offset-based: O(n) - degrades at high offsets (skip 100000 is slow)
5. Rate Limiting and Throttling
Protect your API from abuse:
const rateLimit = require('express-rate-limit');
const RedisStore = require('rate-limit-redis');
const Redis = require('ioredis');
const redis = new Redis({
host: process.env.REDIS_HOST,
port: process.env.REDIS_PORT
});
// Global rate limiting
const globalLimiter = rateLimit({
store: new RedisStore({
client: redis,
prefix: 'rl:global:'
}),
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // max 100 requests per IP
standardHeaders: true,
legacyHeaders: false,
handler: (req, res) => {
res.status(429).json({
error: 'Too Many Requests',
message: 'Rate limit exceeded. Try again later.',
retryAfter: req.rateLimit.resetTime
});
}
});
// Rate limiting for specific endpoints
const strictLimiter = rateLimit({
store: new RedisStore({ client: redis, prefix: 'rl:strict:' }),
windowMs: 60 * 1000,
max: 5
});
// Tiered rate limiting based on authentication
const tieredLimiter = async (req, res, next) => {
const tier = req.user?.tier || 'free';
const limits = {
free: { windowMs: 60000, max: 10 },
pro: { windowMs: 60000, max: 100 },
enterprise: { windowMs: 60000, max: 1000 }
};
const limiter = rateLimit({
store: new RedisStore({
client: redis,
prefix: `rl:${tier}:${req.user?.id}:`
}),
...limits[tier]
});
return limiter(req, res, next);
};
app.use('/api', globalLimiter);
app.post('/api/auth/login', strictLimiter);
app.use('/api/users', tieredLimiter);
GraphQL: Query Flexibility and the Overfetching Solution
GraphQL solves specific REST problems (overfetching, underfetching, multiple roundtrips) at the cost of greater complexity.
When to Use GraphQL
Ideal scenarios:
- Complex frontends with variable data requirements
- Mobile apps on slow connections (precise fetching reduces payload)
- Aggregation from multiple data sources
- Rapid prototyping with changing requirements
When to avoid it:
- Simple public APIs (REST is more standardized)
- Aggressive caching is required (HTTP cache doesn’t work well)
- File upload/download (REST is more efficient)
Schema Design Best Practices
# Well-designed schema with type safety
type User {
id: ID!
name: String!
email: String!
role: UserRole!
createdAt: DateTime!
# Relationships with connection pattern for pagination
orders(
first: Int
after: String
filter: OrderFilter
): OrderConnection!
# Computed fields
fullName: String!
orderCount: Int!
}
enum UserRole {
ADMIN
USER
GUEST
}
type OrderConnection {
edges: [OrderEdge!]!
pageInfo: PageInfo!
totalCount: Int!
}
type OrderEdge {
node: Order!
cursor: String!
}
type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: String
endCursor: String
}
input OrderFilter {
status: OrderStatus
minAmount: Float
dateRange: DateRangeInput
}
# Mutations with input types
input CreateUserInput {
name: String!
email: String!
password: String!
role: UserRole = USER
}
type Mutation {
createUser(input: CreateUserInput!): UserPayload!
updateUser(id: ID!, input: UpdateUserInput!): UserPayload!
deleteUser(id: ID!): DeletePayload!
}
# Consistent response types
type UserPayload {
user: User
errors: [UserError!]
}
type UserError {
field: String!
message: String!
}
N+1 Query Problem and DataLoader
The N+1 problem is the most common performance issue in GraphQL:
const DataLoader = require('dataloader');
// ❌ N+1 Problem - multiple queries
const resolvers = {
Query: {
users: () => User.find()
},
User: {
// This runs 1 query for EVERY user!
orders: (user) => Order.find({ userId: user.id })
}
};
// ✅ Solution with DataLoader - batch loading
class DataLoaders {
constructor() {
this.ordersByUserIdLoader = new DataLoader(async (userIds) => {
// A single query for all userIds
const orders = await Order.find({
userId: { $in: userIds }
});
// Group orders by userId
const ordersByUserId = userIds.map(userId =>
orders.filter(order => order.userId.equals(userId))
);
return ordersByUserId;
});
}
}
const resolvers = {
Query: {
users: () => User.find()
},
User: {
orders: (user, args, context) =>
context.loaders.ordersByUserIdLoader.load(user.id)
}
};
// Context setup
const server = new ApolloServer({
typeDefs,
resolvers,
context: () => ({
loaders: new DataLoaders()
})
});
Performance Impact:
- Without DataLoader: 1 + N queries (N = number of users)
- With DataLoader: 2 total queries (1 for users, 1 batch for orders)
- 95%+ reduction on large datasets
Query Complexity and Depth Limiting
Prevent abusive queries:
const { createComplexityLimitRule } = require('graphql-validation-complexity');
const depthLimit = require('graphql-depth-limit');
const server = new ApolloServer({
typeDefs,
resolvers,
validationRules: [
depthLimit(7), // Max nesting depth
createComplexityLimitRule(1000, {
scalarCost: 1,
objectCost: 5,
listFactor: 10
})
],
plugins: [
{
requestDidStart() {
return {
didResolveOperation({ request, document }) {
const complexity = getComplexity({
schema,
query: document,
variables: request.variables
});
if (complexity > 1000) {
throw new Error(
`Query too complex: ${complexity}. Maximum allowed: 1000`
);
}
console.log('Query complexity:', complexity);
}
};
}
}
]
});
Caching Strategy in GraphQL
GraphQL caching is more complex than REST:
const { InMemoryLRUCache } = require('@apollo/utils.keyvaluecache');
const { responseCachePlugin } = require('apollo-server-plugin-response-cache');
const server = new ApolloServer({
typeDefs,
resolvers,
cache: new InMemoryLRUCache({
maxSize: Math.pow(2, 20) * 100, // 100 MB
ttl: 300 // 5 minutes default
}),
plugins: [
responseCachePlugin({
sessionId: (requestContext) =>
requestContext.request.http?.headers.get('session-id') || null,
extraCacheKeyData: (requestContext) => ({
userId: requestContext.request.http?.headers.get('user-id')
})
})
]
});
// Cache hints in the resolvers
const resolvers = {
Query: {
users: (parent, args, context, info) => {
info.cacheControl.setCacheHint({ maxAge: 60, scope: 'PUBLIC' });
return User.find();
},
me: (parent, args, context, info) => {
info.cacheControl.setCacheHint({ maxAge: 30, scope: 'PRIVATE' });
return context.user;
}
}
};
gRPC: High-Performance RPC for Microservices
gRPC excels at service-to-service communication with extreme performance requirements.
When to Use gRPC
Ideal scenarios:
- Internal microservices (not exposed to the browser)
- Real-time streaming (bidirectional)
- Polyglot environments (strong typing cross-language)
- Critical performance (binary protocol)
Limitations:
- No native browser support (requires grpc-web with a proxy)
- More complex debugging (binary format)
- Steep learning curve
Protocol Buffers Schema
// user.proto
syntax = "proto3";
package user.v1;
import "google/protobuf/timestamp.proto";
service UserService {
rpc GetUser(GetUserRequest) returns (User);
rpc ListUsers(ListUsersRequest) returns (ListUsersResponse);
rpc CreateUser(CreateUserRequest) returns (User);
rpc StreamUsers(StreamUsersRequest) returns (stream User);
rpc UpdateUserStream(stream UpdateUserRequest) returns (stream User);
}
message User {
string id = 1;
string name = 2;
string email = 3;
UserRole role = 4;
google.protobuf.Timestamp created_at = 5;
}
enum UserRole {
USER_ROLE_UNSPECIFIED = 0;
USER_ROLE_ADMIN = 1;
USER_ROLE_USER = 2;
USER_ROLE_GUEST = 3;
}
message GetUserRequest {
string id = 1;
}
message ListUsersRequest {
int32 page_size = 1;
string page_token = 2;
UserFilter filter = 3;
}
message ListUsersResponse {
repeated User users = 1;
string next_page_token = 2;
int32 total_count = 3;
}
message UserFilter {
UserRole role = 1;
google.protobuf.Timestamp created_after = 2;
}
Node.js Server Implementation
const grpc = require('@grpc/grpc-js');
const protoLoader = require('@grpc/proto-loader');
const path = require('path');
// Load proto file
const packageDefinition = protoLoader.loadSync(
path.join(__dirname, 'protos/user.proto'),
{
keepCase: true,
longs: String,
enums: String,
defaults: true,
oneofs: true
}
);
const userProto = grpc.loadPackageDefinition(packageDefinition).user.v1;
// Implement service methods
const userService = {
async getUser(call, callback) {
try {
const user = await User.findById(call.request.id);
if (!user) {
return callback({
code: grpc.status.NOT_FOUND,
message: 'User not found'
});
}
callback(null, {
id: user.id,
name: user.name,
email: user.email,
role: user.role.toUpperCase(),
created_at: { seconds: Math.floor(user.createdAt.getTime() / 1000) }
});
} catch (error) {
callback({
code: grpc.status.INTERNAL,
message: error.message
});
}
},
async listUsers(call, callback) {
try {
const { page_size = 20, page_token, filter } = call.request;
const skip = page_token ? parseInt(Buffer.from(page_token, 'base64').toString()) : 0;
const query = {};
if (filter?.role) {
query.role = filter.role.toLowerCase();
}
const [users, total] = await Promise.all([
User.find(query).skip(skip).limit(page_size + 1),
User.countDocuments(query)
]);
const hasNext = users.length > page_size;
const results = hasNext ? users.slice(0, -1) : users;
const nextPageToken = hasNext
? Buffer.from((skip + page_size).toString()).toString('base64')
: '';
callback(null, {
users: results.map(u => ({
id: u.id,
name: u.name,
email: u.email,
role: u.role.toUpperCase(),
created_at: { seconds: Math.floor(u.createdAt.getTime() / 1000) }
})),
next_page_token: nextPageToken,
total_count: total
});
} catch (error) {
callback({
code: grpc.status.INTERNAL,
message: error.message
});
}
},
// Server streaming
streamUsers(call) {
const stream = User.find().cursor();
stream.on('data', (user) => {
call.write({
id: user.id,
name: user.name,
email: user.email,
role: user.role.toUpperCase(),
created_at: { seconds: Math.floor(user.createdAt.getTime() / 1000) }
});
});
stream.on('end', () => {
call.end();
});
stream.on('error', (error) => {
call.destroy(error);
});
}
};
// Start server
const server = new grpc.Server();
server.addService(userProto.UserService.service, userService);
server.bindAsync(
'0.0.0.0:50051',
grpc.ServerCredentials.createInsecure(),
(error, port) => {
if (error) {
console.error('Server binding failed:', error);
return;
}
console.log(`gRPC server running on port ${port}`);
server.start();
}
);
Performance Benchmarks: REST vs GraphQL vs gRPC
Test scenario: fetch 1000 users with relationships
Operation: List 1000 users with orders
Network: Local (latency ~1ms)
REST:
- Requests: 1 (users) + 1 (all orders batch)
- Payload size: 450 KB (JSON)
- Latency: 85ms
- Throughput: ~11,750 req/s
GraphQL:
- Requests: 1 (single query with nested orders)
- Payload size: 380 KB (JSON, no overfetching)
- Latency: 95ms (DataLoader overhead)
- Throughput: ~10,500 req/s
gRPC:
- Requests: 1 (ListUsers with nested orders)
- Payload size: 180 KB (protobuf binary)
- Latency: 45ms
- Throughput: ~22,200 req/s
Conclusion:
- gRPC: 2x faster, 60% smaller payload
- GraphQL: 15% less payload than REST (no overfetching)
- REST: Simpler, better supported, HTTP caching
API Gateway Pattern
For complex architectures, an API Gateway centralizes cross-cutting concerns:
const express = require('express');
const { createProxyMiddleware } = require('http-proxy-middleware');
const jwt = require('jsonwebtoken');
const app = express();
// Centralized authentication
const authMiddleware = async (req, res, next) => {
const token = req.headers.authorization?.split(' ')[1];
if (!token) {
return res.status(401).json({ error: 'No token provided' });
}
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
req.user = decoded;
next();
} catch (error) {
res.status(401).json({ error: 'Invalid token' });
}
};
// Service routing
app.use('/api/users', authMiddleware, createProxyMiddleware({
target: 'http://user-service:3001',
changeOrigin: true,
pathRewrite: { '^/api/users': '' }
}));
app.use('/api/orders', authMiddleware, createProxyMiddleware({
target: 'http://order-service:3002',
changeOrigin: true,
pathRewrite: { '^/api/orders': '' }
}));
// GraphQL federation
const { ApolloGateway } = require('@apollo/gateway');
const { ApolloServer } = require('apollo-server-express');
const gateway = new ApolloGateway({
serviceList: [
{ name: 'users', url: 'http://user-service:4001/graphql' },
{ name: 'orders', url: 'http://order-service:4002/graphql' }
]
});
const server = new ApolloServer({ gateway });
await server.start();
server.applyMiddleware({ app, path: '/graphql' });
app.listen(3000, () => {
console.log('API Gateway running on port 3000');
});
Conclusions and Decision Matrix
Choose REST when:
- Public API with external consumers
- Simple CRUD operations
- HTTP caching is critical
- Simplicity and standardization are priorities
Choose GraphQL when:
- Complex frontends with variable data
- Mobile-first (payload optimization)
- Rapid iteration on the schema
- Aggregation from multiple sources
Choose gRPC when:
- Internal microservices
- Critical performance (low latency, high throughput)
- Bidirectional streaming
- Strong typing cross-language
Hybrid approach: Use an API Gateway to expose REST/GraphQL publicly while microservices communicate via gRPC internally.
The right choice depends on the context. There’s no universal solution, but understanding the trade-offs lets you make informed architectural decisions.