API Design Best Practices: REST vs GraphQL vs gRPC
API Design Best Practices: REST vs GraphQL vs gRPC
Choosing an API paradigm is one of the most consequential early decisions in a project. Get it right, and your API becomes a pleasure to use. Get it wrong, and you'll spend years working around the limitations. The good news: the choice is usually obvious once you understand the trade-offs.
REST, GraphQL, and gRPC at a Glance
| Aspect | REST | GraphQL | gRPC |
|---|---|---|---|
| Protocol | HTTP/1.1 or HTTP/2 | HTTP/1.1 or HTTP/2 | HTTP/2 (required) |
| Data format | JSON (typically) | JSON | Protocol Buffers (binary) |
| Schema | Optional (OpenAPI) | Required (SDL) | Required (.proto) |
| Overfetching | Common | Solved by design | Solved by design |
| Browser support | Native | Native | Requires grpc-web proxy |
| Caching | HTTP caching works naturally | Complex (POST-based) | Not built-in |
| Learning curve | Low | Medium | High |
| Best for | Public APIs, CRUD apps | Complex frontends, mobile | Microservices, high performance |
The short version: REST is the default for public APIs, GraphQL shines when frontends need flexible data fetching, and gRPC is the right choice for internal service-to-service communication where performance matters.
REST API Design That Doesn't Suck
REST is the most common API style, but most REST APIs are poorly designed. Following a few principles makes the difference between an API developers love and one they dread.
Resource-Oriented URLs
URLs should represent resources (nouns), not actions (verbs). The HTTP method provides the verb.
# Good -- resources as nouns
GET /users # List users
POST /users # Create a user
GET /users/123 # Get a specific user
PATCH /users/123 # Update a user
DELETE /users/123 # Delete a user
# Bad -- verbs in URLs
POST /createUser
GET /getUserById?id=123
POST /updateUser
POST /deleteUser
Nested resources should reflect real relationships, but keep nesting shallow:
# Good -- one level of nesting
GET /users/123/orders # Orders belonging to user 123
GET /orders/456 # Direct access to order 456
# Bad -- deep nesting
GET /users/123/orders/456/items/789/reviews
Use the Right HTTP Methods and Status Codes
# Express-style pseudocode showing proper method + status code usage
# 200 OK -- successful GET, PATCH, DELETE
GET /users/123 -> 200 { "id": 123, "name": "Alice" }
# 201 Created -- successful POST that creates a resource
POST /users -> 201 { "id": 124, "name": "Bob" }
Location: /users/124
# 204 No Content -- successful DELETE with no response body
DELETE /users/123 -> 204
# 400 Bad Request -- client sent invalid data
POST /users { "email": "not-an-email" } -> 400
# 404 Not Found -- resource doesn't exist
GET /users/99999 -> 404
# 409 Conflict -- resource state conflict
POST /users { "email": "[email protected]" } -> 409 # email taken
# 422 Unprocessable Entity -- valid JSON but failed validation
POST /users { "name": "" } -> 422
Consistent Error Responses
Every API needs a consistent error format. Don't make clients guess the shape of error responses.
{
"error": {
"code": "VALIDATION_FAILED",
"message": "The request body contains invalid fields.",
"details": [
{
"field": "email",
"message": "Must be a valid email address.",
"value": "not-an-email"
},
{
"field": "age",
"message": "Must be a positive integer.",
"value": -5
}
]
}
}
Here's a reusable error handler in Express:
// errors.ts
class ApiError extends Error {
constructor(
public statusCode: number,
public code: string,
message: string,
public details?: Array<{ field: string; message: string; value?: unknown }>
) {
super(message);
}
}
// Error handler middleware
function errorHandler(err: Error, req: Request, res: Response, next: NextFunction) {
if (err instanceof ApiError) {
return res.status(err.statusCode).json({
error: {
code: err.code,
message: err.message,
details: err.details,
},
});
}
// Unexpected errors -- don't leak internals
console.error("Unhandled error:", err);
return res.status(500).json({
error: {
code: "INTERNAL_ERROR",
message: "An unexpected error occurred.",
},
});
}
// Usage in a route
app.post("/users", async (req, res, next) => {
try {
const errors = validateUser(req.body);
if (errors.length > 0) {
throw new ApiError(422, "VALIDATION_FAILED", "Invalid input.", errors);
}
const user = await createUser(req.body);
res.status(201).json(user);
} catch (err) {
next(err);
}
});
Pagination Done Right
There are three common pagination approaches. Offset-based is the simplest but worst at scale. Cursor-based is better for most real applications.
Offset-Based Pagination
GET /users?offset=20&limit=10
{
"data": [...],
"pagination": {
"offset": 20,
"limit": 10,
"total": 253
}
}
Problems: Slow on large tables (SQL OFFSET scans rows), inconsistent results when data changes between pages. Fine for admin dashboards with small datasets, bad for everything else.
Cursor-Based Pagination
GET /users?limit=10&after=eyJpZCI6MjB9
{
"data": [...],
"pagination": {
"has_next": true,
"next_cursor": "eyJpZCI6MzB9",
"has_previous": true,
"previous_cursor": "eyJpZCI6MjF9"
}
}
The cursor is an opaque string (typically a base64-encoded primary key or timestamp). The server uses it to efficiently query the next page:
-- Instead of OFFSET (slow):
SELECT * FROM users ORDER BY id LIMIT 10 OFFSET 10000;
-- Cursor-based (fast, uses index):
SELECT * FROM users WHERE id > 10000 ORDER BY id LIMIT 10;
Keyset Pagination Implementation
// Cursor-based pagination with Prisma
async function listUsers(cursor?: string, limit = 20) {
const decodedCursor = cursor
? JSON.parse(Buffer.from(cursor, "base64url").toString())
: null;
const users = await prisma.user.findMany({
take: limit + 1, // Fetch one extra to check for next page
...(decodedCursor && {
cursor: { id: decodedCursor.id },
skip: 1, // Skip the cursor item itself
}),
orderBy: { id: "asc" },
});
const hasNext = users.length > limit;
const data = hasNext ? users.slice(0, -1) : users;
return {
data,
pagination: {
has_next: hasNext,
next_cursor: hasNext
? Buffer.from(JSON.stringify({ id: data[data.length - 1].id })).toString("base64url")
: null,
},
};
}
API Versioning
There are three approaches to versioning, and the industry has largely settled on URL-based versioning for simplicity:
| Strategy | Example | Pros | Cons |
|---|---|---|---|
| URL path | /v1/users |
Obvious, easy to route | URL pollution |
| Header | Accept: application/vnd.api.v1+json |
Clean URLs | Hidden, harder to test |
| Query param | /users?version=1 |
Easy to add | Easy to forget |
URL-Based Versioning (Recommended)
// Express router setup
import { Router } from "express";
import v1Routes from "./routes/v1";
import v2Routes from "./routes/v2";
const app = express();
app.use("/v1", v1Routes);
app.use("/v2", v2Routes);
// v1 routes -- maintained for backward compatibility
// v2 routes -- current version with breaking changes
When to Version
Don't version eagerly. Only create a new version when you need to make a breaking change. These are breaking:
- Removing a field from a response
- Changing a field's type (string to integer)
- Renaming a field
- Changing authentication requirements
- Changing error response format
These are not breaking:
- Adding a new field to a response
- Adding a new optional query parameter
- Adding a new endpoint
- Adding a new enum value (if clients are tolerant)
GraphQL: When Flexible Queries Matter
GraphQL solves the overfetching/underfetching problem that plagues REST APIs. Instead of the server deciding what data to return, the client asks for exactly what it needs.
# Schema definition
type User {
id: ID!
name: String!
email: String!
orders(first: Int, after: String): OrderConnection!
avatar: String
}
type Order {
id: ID!
total: Float!
status: OrderStatus!
items: [OrderItem!]!
}
type Query {
user(id: ID!): User
users(first: Int, after: String, filter: UserFilter): UserConnection!
}
input UserFilter {
search: String
status: UserStatus
}
# Client query -- gets exactly what the mobile app needs
query UserProfile($userId: ID!) {
user(id: $userId) {
name
avatar
orders(first: 5) {
edges {
node {
total
status
}
}
}
}
}
GraphQL's Real Trade-offs
Advantages:
- Clients get exactly the data they need (no overfetching)
- One endpoint for everything (no endpoint sprawl)
- Strongly typed schema serves as documentation
- Great for mobile apps where bandwidth matters
Disadvantages:
- HTTP caching is nearly impossible (everything is POST to one endpoint)
- N+1 query problem is easy to create, requires DataLoader to solve
- Rate limiting is harder (can't just limit per-endpoint)
- File uploads are awkward
- Error handling is non-standard (always returns 200)
The DataLoader Pattern
Without DataLoader, a query for 50 users with their orders creates 51 database queries. DataLoader batches them:
import DataLoader from "dataloader";
// Without DataLoader -- N+1 problem
const resolvers = {
User: {
orders: (user) => db.orders.findMany({ where: { userId: user.id } }),
// Called once per user = 50 queries for 50 users
},
};
// With DataLoader -- batched
const orderLoader = new DataLoader(async (userIds: string[]) => {
const orders = await db.orders.findMany({
where: { userId: { in: userIds } },
});
// Group by userId and return in same order as input
return userIds.map((id) => orders.filter((o) => o.userId === id));
});
const resolvers = {
User: {
orders: (user) => orderLoader.load(user.id),
// All 50 user IDs batched into a single query
},
};
gRPC: The Microservices Choice
gRPC uses Protocol Buffers for serialization and HTTP/2 for transport. It's significantly faster than JSON-over-HTTP for service-to-service communication.
// user.proto
syntax = "proto3";
package user;
service UserService {
rpc GetUser (GetUserRequest) returns (User);
rpc ListUsers (ListUsersRequest) returns (ListUsersResponse);
rpc CreateUser (CreateUserRequest) returns (User);
rpc StreamUpdates (StreamRequest) returns (stream UserEvent);
}
message User {
string id = 1;
string name = 2;
string email = 3;
int64 created_at = 4;
}
message GetUserRequest {
string id = 1;
}
message ListUsersRequest {
int32 page_size = 1;
string page_token = 2;
}
message ListUsersResponse {
repeated User users = 1;
string next_page_token = 2;
}
When gRPC Makes Sense
- Internal microservices: The binary format and HTTP/2 multiplexing give measurable performance wins
- Streaming: gRPC has native support for server streaming, client streaming, and bidirectional streaming
- Polyglot services: Proto files generate type-safe clients in any language
- Mobile backends: Smaller payloads and persistent connections reduce battery usage
When gRPC Doesn't Make Sense
- Public APIs: Browser support requires a proxy (grpc-web), and most third-party developers expect REST or GraphQL
- Simple CRUD: The tooling overhead isn't worth it for basic operations
- Small teams: The learning curve for protobuf, code generation, and gRPC concepts is real
Rate Limiting and Throttling
Every API needs rate limiting. The standard approach uses response headers:
HTTP/1.1 200 OK
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 95
X-RateLimit-Reset: 1706547200
HTTP/1.1 429 Too Many Requests
Retry-After: 30
Token Bucket Implementation
import { Redis } from "ioredis";
const redis = new Redis();
async function checkRateLimit(
key: string,
limit: number,
windowSeconds: number
): Promise<{ allowed: boolean; remaining: number; resetAt: number }> {
const now = Math.floor(Date.now() / 1000);
const windowStart = now - windowSeconds;
const pipeline = redis.pipeline();
// Remove old entries
pipeline.zremrangebyscore(key, 0, windowStart);
// Add current request
pipeline.zadd(key, now.toString(), `${now}-${Math.random()}`);
// Count requests in window
pipeline.zcard(key);
// Set expiry
pipeline.expire(key, windowSeconds);
const results = await pipeline.exec();
const count = results![2][1] as number;
return {
allowed: count <= limit,
remaining: Math.max(0, limit - count),
resetAt: now + windowSeconds,
};
}
Choosing Your API Style: Decision Framework
Choose REST when:
- Building a public API that third-party developers will consume
- Your data model maps naturally to CRUD operations
- You want HTTP caching to work out of the box
- Your team is most comfortable with REST
Choose GraphQL when:
- Multiple frontends (web, mobile, watch) need different data shapes
- You're building a data-rich application with complex relationships
- Frontend teams want to iterate without backend changes
- You're aggregating data from multiple backend services
Choose gRPC when:
- Building internal service-to-service communication
- Performance and bandwidth efficiency matter (high-throughput systems)
- You need streaming (real-time events, file transfers)
- Services are written in multiple languages
Hybrid approaches work too:
- REST for public API + gRPC between internal services
- GraphQL as a BFF (Backend for Frontend) that calls REST/gRPC services
- REST for simple resources + WebSockets for real-time features
Common API Design Mistakes
Inconsistent naming: Mixing
camelCaseandsnake_casein the same API. Pick one and stick with it.Leaking internal structure: Your API shouldn't mirror your database schema. Clients don't need to know about your
user_profile_settings_v2table.Ignoring idempotency: POST requests that create duplicate resources on retry. Use idempotency keys:
app.post("/payments", async (req, res) => {
const idempotencyKey = req.headers["idempotency-key"];
if (!idempotencyKey) {
return res.status(400).json({ error: "Idempotency-Key header required" });
}
const existing = await cache.get(`idempotency:${idempotencyKey}`);
if (existing) {
return res.status(200).json(JSON.parse(existing));
}
const payment = await processPayment(req.body);
await cache.set(`idempotency:${idempotencyKey}`, JSON.stringify(payment), "EX", 86400);
res.status(201).json(payment);
});
No request validation: Always validate input at the API boundary, not deep in your business logic.
Returning 200 for everything: Status codes exist for a reason. A 200 response with
{ "success": false }is an anti-pattern.
Summary
API design isn't about picking the "best" technology. It's about understanding your constraints -- who's consuming the API, what data patterns you have, how important performance is -- and choosing the tool that fits. REST remains the safe default for most projects. GraphQL adds real value when data fetching complexity is your bottleneck. gRPC is the right choice when raw performance between services matters more than developer ergonomics.
Whatever you choose, invest in consistency. A well-designed REST API beats a poorly-designed GraphQL API every time.