HTTP Best Practices for REST APIs
Building a REST API means working with HTTP, not against it. The protocol provides semantics for status codes, caching, content negotiation, and more—use them. This article covers the practical patterns that make APIs predictable, cacheable, and easy to debug.
HTTP Status Codes
Status codes tell clients what happened without parsing response bodies. Use them semantically.
2xx — Success
| Code | Meaning | When to Use |
|---|---|---|
200 OK |
Request succeeded | GET, PUT, PATCH that returns data |
201 Created |
Resource created | POST that creates a new resource |
202 Accepted |
Request accepted for processing | Async operations (background jobs) |
204 No Content |
Success, no body | DELETE, or PUT/PATCH with no response body |
// 200 OK - Successful GET or update
router.get('/users/:id', async (req, res) => {
const user = await db.user.findUnique({ where: { id: req.params.id } });
if (!user) return res.status(404).json({ error: 'User not found' });
res.status(200).json({ data: user });
});
// 201 Created - New resource
router.post('/users', async (req, res) => {
const user = await db.user.create({ data: req.body });
// Include Location header pointing to new resource
res.status(201)
.header('Location', `/users/${user.id}`)
.json({ data: user });
});
// 202 Accepted - Async job queued
router.post('/reports/generate', async (req, res) => {
const job = await queue.add('generate-report', { userId: req.user.id });
res.status(202).json({
data: {
jobId: job.id,
status: 'pending',
statusUrl: `/jobs/${job.id}`,
},
});
});
// 204 No Content - Successful delete
router.delete('/users/:id', async (req, res) => {
await db.user.delete({ where: { id: req.params.id } });
res.status(204).send();
});
3xx — Redirection
| Code | Meaning | When to Use |
|---|---|---|
301 Moved Permanently |
Resource moved forever | Old URL permanently redirects |
302 Found |
Temporary redirect | Temporary redirect (maintains method) |
304 Not Modified |
Cached version valid | Response to conditional GET |
307 Temporary Redirect |
Temporary, preserve method | Redirect POST without changing to GET |
308 Permanent Redirect |
Permanent, preserve method | Permanent redirect for non-GET |
// 301 - Permanent redirect (old API version)
router.get('/v1/users', (req, res) => {
res.redirect(301, '/v2/users');
});
// 304 - Conditional GET with ETag
router.get('/posts/:id', async (req, res) => {
const post = await db.post.findUnique({ where: { id: req.params.id } });
const etag = generateETag(post);
// Client sent If-None-Match header
if (req.headers['if-none-match'] === etag) {
return res.status(304).send();
}
res.setHeader('ETag', etag);
res.json({ data: post });
});
4xx — Client Errors
| Code | Meaning | When to Use |
|---|---|---|
400 Bad Request |
Malformed request | Invalid JSON, missing required fields |
401 Unauthorized |
Not authenticated | No token, expired token, invalid token |
403 Forbidden |
Not authorized | Valid auth, but no permission |
404 Not Found |
Resource doesn't exist | ID not in database |
405 Method Not Allowed |
HTTP method not supported | POST to read-only endpoint |
409 Conflict |
Resource state conflict | Duplicate email, concurrent edit conflict |
410 Gone |
Resource permanently deleted | Soft-deleted resource that won't return |
422 Unprocessable Entity |
Semantic error | Valid JSON but business logic fails |
429 Too Many Requests |
Rate limited | Slow down, include Retry-After |
// 400 Bad Request - Invalid input
router.post('/users', async (req, res) => {
const result = userSchema.safeParse(req.body);
if (!result.success) {
return res.status(400).json({
error: {
code: 'VALIDATION_ERROR',
message: 'Invalid request body',
details: result.error.flatten().fieldErrors,
},
});
}
// ...
});
// 401 Unauthorized - Not authenticated
const authMiddleware = (req, res, next) => {
const token = req.headers.authorization?.replace('Bearer ', '');
if (!token) {
return res.status(401).json({
error: {
code: 'UNAUTHORIZED',
message: 'Authentication required',
},
});
}
try {
req.user = verifyToken(token);
next();
} catch {
return res.status(401).json({
error: {
code: 'INVALID_TOKEN',
message: 'Token is invalid or expired',
},
});
}
};
// 403 Forbidden - Authenticated but not authorized
router.delete('/posts/:id', async (req, res) => {
const post = await db.post.findUnique({ where: { id: req.params.id } });
if (!post) {
return res.status(404).json({ error: { code: 'NOT_FOUND' } });
}
if (post.authorId !== req.user.id && req.user.role !== 'admin') {
return res.status(403).json({
error: {
code: 'FORBIDDEN',
message: 'You do not have permission to delete this post',
},
});
}
// ...
});
// 409 Conflict - Duplicate resource
router.post('/users', async (req, res) => {
const existing = await db.user.findUnique({
where: { email: req.body.email },
});
if (existing) {
return res.status(409).json({
error: {
code: 'DUPLICATE_EMAIL',
message: 'A user with this email already exists',
},
});
}
// ...
});
// 422 Unprocessable Entity - Business logic error
router.post('/orders', async (req, res) => {
const product = await db.product.findUnique({ where: { id: req.body.productId } });
if (product.stock < req.body.quantity) {
return res.status(422).json({
error: {
code: 'INSUFFICIENT_STOCK',
message: `Only ${product.stock} items available`,
available: product.stock,
},
});
}
// ...
});
// 429 Rate Limited
router.use('/api', rateLimit({
windowMs: 60 * 1000,
max: 100,
handler: (req, res) => {
res.status(429)
.header('Retry-After', '60')
.json({
error: {
code: 'RATE_LIMITED',
message: 'Too many requests, please try again later',
retryAfter: 60,
},
});
},
}));
5xx — Server Errors
| Code | Meaning | When to Use |
|---|---|---|
500 Internal Server Error |
Unexpected error | Unhandled exception, bug |
502 Bad Gateway |
Upstream failure | Database down, service unavailable |
503 Service Unavailable |
Temporarily down | Maintenance, overloaded |
504 Gateway Timeout |
Upstream timeout | Slow dependency |
// Global error handler - catch unhandled errors
app.use((err, req, res, next) => {
console.error('Unhandled error:', err);
// Don't leak internal details
res.status(500).json({
error: {
code: 'INTERNAL_ERROR',
message: 'An unexpected error occurred',
requestId: req.id, // For support correlation
},
});
});
// 503 during maintenance
if (process.env.MAINTENANCE_MODE === 'true') {
app.use((req, res) => {
res.status(503)
.header('Retry-After', '3600')
.json({
error: {
code: 'MAINTENANCE',
message: 'Service temporarily unavailable for maintenance',
},
});
});
}
Error Response Format
Use a consistent error structure across your entire API:
// src/types/api.ts
interface ApiError {
error: {
code: string; // Machine-readable code (VALIDATION_ERROR)
message: string; // Human-readable message
details?: unknown; // Additional context (field errors, etc)
requestId?: string; // For support/debugging
};
}
// Example error responses
// 400 Validation Error
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Invalid request body",
"details": {
"email": ["Invalid email format"],
"name": ["Required"]
},
"requestId": "req_abc123"
}
}
// 404 Not Found
{
"error": {
"code": "NOT_FOUND",
"message": "User not found",
"requestId": "req_abc123"
}
}
// 500 Internal Error
{
"error": {
"code": "INTERNAL_ERROR",
"message": "An unexpected error occurred",
"requestId": "req_abc123"
}
}
API Versioning
APIs evolve. Breaking changes happen. Versioning strategies help you migrate clients gracefully.
URI Versioning
Version is part of the URL path. Most explicit and widely used.
GET /v1/users
GET /v2/users
// src/routes/v1/users.ts
const v1Router = Router();
v1Router.get('/users', (req, res) => {
// v1 returns flat user object
res.json({ data: users });
});
// src/routes/v2/users.ts
const v2Router = Router();
v2Router.get('/users', (req, res) => {
// v2 includes nested profile
res.json({ data: usersWithProfiles });
});
// src/app.ts
app.use('/v1', v1Router);
app.use('/v2', v2Router);
Pros:
- Obvious which version you're using
- Easy to test in browser
- Cache-friendly (different URLs)
Cons:
- URL changes when version changes
- Clients need to update all URLs
Header Versioning
Version in custom header or Accept header.
GET /users
Accept-Version: v1
# Or using content negotiation
GET /users
Accept: application/vnd.myapi.v1+json
const versionMiddleware = (req, res, next) => {
const version = req.headers['accept-version'] || 'v2'; // Default to latest
req.apiVersion = version;
next();
};
router.get('/users', (req, res) => {
if (req.apiVersion === 'v1') {
return res.json({ data: v1Response });
}
res.json({ data: v2Response });
});
Pros:
- URLs stay clean
- Single endpoint serves multiple versions
Cons:
- Can't test easily in browser
- Caching more complex
- Easy to forget the header
Query Parameter Versioning
GET /users?v=1
GET /users?version=2
router.get('/users', (req, res) => {
const version = req.query.v || req.query.version || '2';
if (version === '1') {
return res.json({ data: v1Response });
}
res.json({ data: v2Response });
});
Pros:
- Easy to switch versions
- Works in browser
Cons:
- Non-standard
- Pollutes URL with meta-info
- Optional parameter can be forgotten
Recommendation
Use URI versioning for public APIs. It's explicit, standard, and cache-friendly. The "ugly URL" argument is weak—clarity beats aesthetics for APIs.
# Production API
https://api.example.com/v1/users
# When v2 is ready, clients migrate at their pace
https://api.example.com/v2/users
# Eventually deprecate v1
https://api.example.com/v1/* → 410 Gone (with migration guide)
Pagination Patterns
Any endpoint that returns a list needs pagination. Choose based on your data access patterns.
Offset-Based Pagination
Traditional approach using limit and offset.
GET /posts?limit=20&offset=0 # Page 1
GET /posts?limit=20&offset=20 # Page 2
GET /posts?limit=20&offset=40 # Page 3
// src/routes/posts.ts
router.get('/posts', async (req, res) => {
const limit = Math.min(Number(req.query.limit) || 20, 100);
const offset = Number(req.query.offset) || 0;
const [posts, total] = await Promise.all([
db.post.findMany({
take: limit,
skip: offset,
orderBy: { createdAt: 'desc' },
}),
db.post.count(),
]);
res.json({
data: posts,
meta: {
total,
limit,
offset,
hasMore: offset + posts.length < total,
},
});
});
Pros:
- Simple to implement
- Can jump to any page
- Easy to show "Page X of Y"
Cons:
- Performance degrades with large offsets (
OFFSET 10000scans 10000 rows) - Inconsistent results if data changes between requests (items shift)
Cursor-Based Pagination
Uses an opaque cursor (usually the last item's ID) to fetch the next page.
GET /posts?limit=20 # First page
GET /posts?limit=20&after=post_abc123 # Next page (after cursor)
// src/routes/posts.ts
router.get('/posts', async (req, res) => {
const limit = Math.min(Number(req.query.limit) || 20, 100);
const cursor = req.query.after;
// Fetch one extra to check if there's more
const posts = await db.post.findMany({
take: limit + 1,
cursor: cursor ? { id: cursor } : undefined,
skip: cursor ? 1 : 0, // Skip the cursor item itself
orderBy: { createdAt: 'desc' },
});
const hasMore = posts.length > limit;
const data = hasMore ? posts.slice(0, -1) : posts;
res.json({
data,
meta: {
hasMore,
nextCursor: hasMore ? data[data.length - 1].id : null,
},
});
});
Pros:
- Consistent performance regardless of position
- Stable results even if data changes
- Works well with real-time data
Cons:
- Can't jump to arbitrary page
- Harder to show "Page X of Y"
- Cursor must be stable (ID-based, not position-based)
Keyset Pagination
Similar to cursor, but uses actual column values instead of opaque cursors. More flexible for sorting.
GET /posts?limit=20
GET /posts?limit=20&after_date=2024-01-15&after_id=abc123
// Keyset pagination with compound sort
router.get('/posts', async (req, res) => {
const limit = Math.min(Number(req.query.limit) || 20, 100);
const afterDate = req.query.after_date;
const afterId = req.query.after_id;
let whereClause = {};
if (afterDate && afterId) {
// Posts older than cursor, or same date but lower ID
whereClause = {
OR: [
{ createdAt: { lt: new Date(afterDate) } },
{
AND: [
{ createdAt: new Date(afterDate) },
{ id: { lt: afterId } },
],
},
],
};
}
const posts = await db.post.findMany({
where: whereClause,
take: limit + 1,
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
});
const hasMore = posts.length > limit;
const data = hasMore ? posts.slice(0, -1) : posts;
const lastItem = data[data.length - 1];
res.json({
data,
meta: {
hasMore,
nextCursor: hasMore
? { after_date: lastItem.createdAt.toISOString(), after_id: lastItem.id }
: null,
},
});
});
Pagination Recommendation
| Use Case | Recommendation |
|---|---|
| Admin dashboards | Offset (need page numbers, datasets are manageable) |
| Infinite scroll | Cursor (consistent, performant) |
| Large datasets | Cursor or keyset (offset will timeout) |
| Sorted by multiple columns | Keyset (cursor can encode sort state) |
Caching
HTTP caching reduces server load and improves response times. Use it for read-heavy endpoints.
Cache-Control Header
// Immutable resource (versioned asset)
router.get('/assets/:hash', (req, res) => {
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
res.sendFile(assetPath);
});
// Public cacheable for 5 minutes
router.get('/posts', async (req, res) => {
res.setHeader('Cache-Control', 'public, max-age=300');
res.json({ data: await getPosts() });
});
// Private (user-specific), cacheable for 1 minute
router.get('/profile', authMiddleware, async (req, res) => {
res.setHeader('Cache-Control', 'private, max-age=60');
res.json({ data: await getProfile(req.user.id) });
});
// No cache (sensitive data)
router.get('/account/balance', authMiddleware, async (req, res) => {
res.setHeader('Cache-Control', 'no-store');
res.json({ data: await getBalance(req.user.id) });
});
ETag for Conditional Requests
ETags let clients ask "has this changed?"
import { createHash } from 'crypto';
const generateETag = (data: unknown): string => {
const hash = createHash('md5')
.update(JSON.stringify(data))
.digest('hex');
return `"${hash}"`;
};
router.get('/posts/:id', async (req, res) => {
const post = await db.post.findUnique({ where: { id: req.params.id } });
if (!post) {
return res.status(404).json({ error: { code: 'NOT_FOUND' } });
}
const etag = generateETag(post);
// Check if client has current version
if (req.headers['if-none-match'] === etag) {
return res.status(304).send();
}
res.setHeader('ETag', etag);
res.setHeader('Cache-Control', 'private, max-age=0, must-revalidate');
res.json({ data: post });
});
Vary Header
Tell caches that response varies based on certain request headers:
// Response varies by authorization (different users get different data)
res.setHeader('Vary', 'Authorization');
// Response varies by accept-encoding (gzip vs plain)
res.setHeader('Vary', 'Accept-Encoding');
// Multiple vary headers
res.setHeader('Vary', 'Authorization, Accept-Language');
Content Negotiation
Support multiple response formats using the Accept header.
router.get('/posts/:id', async (req, res) => {
const post = await db.post.findUnique({ where: { id: req.params.id } });
if (!post) {
return res.status(404).json({ error: { code: 'NOT_FOUND' } });
}
const accept = req.headers.accept || 'application/json';
if (accept.includes('application/xml')) {
res.type('application/xml');
return res.send(toXML(post));
}
if (accept.includes('text/csv')) {
res.type('text/csv');
return res.send(toCSV([post]));
}
// Default to JSON
res.json({ data: post });
});
Idempotency
Idempotent operations can be safely retried. GET, PUT, DELETE are idempotent by design. POST typically isn't—creating a resource twice creates duplicates.
Idempotency Keys
For non-idempotent operations, accept a client-generated key:
// src/middleware/idempotency.ts
const idempotencyCache = new Map<string, { status: number; body: unknown }>();
export const idempotencyMiddleware = async (req, res, next) => {
const key = req.headers['idempotency-key'];
if (!key) {
return next();
}
// Check if we've seen this request before
const cached = idempotencyCache.get(key);
if (cached) {
return res.status(cached.status).json(cached.body);
}
// Capture the response
const originalJson = res.json.bind(res);
res.json = (body) => {
idempotencyCache.set(key, { status: res.statusCode, body });
// Expire after 24 hours
setTimeout(() => idempotencyCache.delete(key), 24 * 60 * 60 * 1000);
return originalJson(body);
};
next();
};
// Usage
router.post('/payments', idempotencyMiddleware, async (req, res) => {
const payment = await processPayment(req.body);
res.status(201).json({ data: payment });
});
Client usage:
// Client generates unique key per logical operation
const response = await fetch('/api/payments', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Idempotency-Key': crypto.randomUUID(),
},
body: JSON.stringify({ amount: 1000 }),
});
// Safe to retry on network error - same key returns same response
Common Gotchas
1. Using 200 for Everything
Don't return 200 OK with an error body. Status codes exist for a reason.
// BAD
res.status(200).json({ success: false, error: 'Not found' });
// GOOD
res.status(404).json({ error: { code: 'NOT_FOUND', message: 'User not found' } });
2. Exposing Internal Errors
Never expose stack traces or internal details to clients.
// BAD
res.status(500).json({ error: err.stack });
// GOOD
console.error('Internal error:', err);
res.status(500).json({
error: {
code: 'INTERNAL_ERROR',
message: 'An unexpected error occurred',
requestId: req.id,
},
});
3. Ignoring Accept Headers
If a client asks for XML and you only support JSON, return 406 Not Acceptable.
if (req.headers.accept === 'application/xml') {
return res.status(406).json({
error: {
code: 'NOT_ACCEPTABLE',
message: 'XML format not supported. Use application/json.',
},
});
}
4. Large Payloads Without Limits
Always limit request body size:
app.use(express.json({ limit: '1mb' }));
// Or per-route for file uploads
router.post('/upload', express.json({ limit: '10mb' }), uploadHandler);
See Also
- Paradigms: REST vs GraphQL vs RPC — Choosing the right approach
- Authentication — OAuth, sessions, JWTs
- Authorization — RBAC and ABAC patterns
- MDN HTTP Status Codes
- HTTP Caching (MDN)