The 200 OK Anti-Pattern
There is an infamous anti-pattern in amateur backend development: the server catches a database connection failure, crashes on a null pointer, and still responds with HTTP 200 OK containing {"success": false, "error": "User not found"}.
Frontend developers hate this. Monitoring tools like Datadog and CloudWatch think your API has a 100% uptime record, while your actual users are staring at broken checkout screens.
Representational State Transfer (REST) is not just a buzzword to paste into your resume. It is an engineering contract that uses the HTTP specification to communicate intent clearly. Here is how to build production APIs that treat HTTP with technical rigor.
1. Verb Semantics: What They Actually Mean
Do not use POST for everything. HTTP verbs specify the exact side effects of a network operation:
- GET: Safe and idempotent. Must never mutate server state. Calling GET 1,000 times must produce the exact same outcome as calling it once.
- POST: Neither safe nor idempotent. Creates subordinate resources. Every call spawns a new record or triggers a distinct action.
- PUT: Idempotent replacement. Replaces the complete target resource with the uploaded payload. If fields are omitted, they are reset to defaults or erased.
- PATCH: Partial update. Applies delta changes to specific fields while leaving unmodified properties intact.
- DELETE: Idempotent removal. Deleting a resource that was already deleted should still return a clean response without breaking the client.
2. Status Code Precision: Stop Guessing Numbers
You do not need to memorize all 60 HTTP status codes, but you must master these nine fundamental responses:
- 200 OK: Standard success for GET, PUT, or PATCH.
- 201 Created: Resource successfully created via POST. Always pair this with a
Locationheader pointing to the new resource URI. - 204 No Content: Successful deletion or update where the client does not need a response body.
- 400 Bad Request: The client passed invalid JSON syntax, missing required schema fields, or out-of-range values.
- 401 Unauthorized: The client provided missing or invalid authentication credentials (bearer token, session cookie).
- 403 Forbidden: The client is authenticated, but their user role lacks permission to touch this resource.
- 404 Not Found: The requested resource URI does not exist on this server.
- 409 Conflict: State collision (such as trying to register a user with an email that already exists in the database).
- 429 Too Many Requests: Rate limit exceeded. Include a
Retry-Afterheader in seconds.
3. Payment Safety: The Idempotency Key Pattern
When an Indian customer clicks 'Pay ₹4,999' on a slow 4G mobile connection, their mobile browser might time out before the server response returns. The user panics and clicks the pay button three more times.
Without idempotency handling, your payment service charges their debit card four times. You get customer support tickets, chargebacks, and legal notices.
Fix this with an Idempotency-Key header stored in Redis with a 24-hour expiration time:
import { Request, Response, NextFunction } from 'express';
import Redis from 'ioredis';
const redis = new Redis(process.env.REDIS_URL || 'redis://localhost:6379');
export async function enforceIdempotency(req: Request, res: Response, next: NextFunction) {
const idempotencyKey = req.header('Idempotency-Key');
// Only enforce for mutating POST requests
if (req.method !== 'POST' || !idempotencyKey) {
return next();
}
const cacheKey = `idempotency:${req.user?.id}:${idempotencyKey}`;
const cachedResponse = await redis.get(cacheKey);
if (cachedResponse) {
const { status, body } = JSON.parse(cachedResponse);
return res.status(status).json(body);
}
// Intercept response to cache result on completion
const originalJson = res.json.bind(res);
res.json = (body: any) => {
if (res.statusCode >= 200 && res.statusCode < 300) {
redis.set(cacheKey, JSON.stringify({ status: res.statusCode, body }), 'EX', 86400);
}
return originalJson(body);
};
next();
}
If the client repeats the request with the identical key, your server bypasses the payment processor entirely and returns the original cached response. Zero double charges.
4. Defending Your Server: Rate Limiting with Redis
An open API endpoint without rate limits is an invitation for web scrapers and denial-of-service crashes. Implementing a Token Bucket algorithm in Redis gives you distributed protection across multiple server instances:
export async function rateLimiter(req: Request, res: Response, next: NextFunction) {
const clientIp = req.ip || req.socket.remoteAddress || 'unknown';
const bucketKey = `ratelimit:${clientIp}`;
const limit = 60; // 60 requests per minute
const currentCount = await redis.incr(bucketKey);
if (currentCount === 1) {
await redis.expire(bucketKey, 60);
}
res.setHeader('X-RateLimit-Limit', limit);
res.setHeader('X-RateLimit-Remaining', Math.max(0, limit - currentCount));
if (currentCount > limit) {
res.setHeader('Retry-After', 60);
return res.status(429).json({
error: 'Rate limit exceeded. Please wait 60 seconds before retrying.'
});
}
next();
}
5. Production API Design Checklist
Before launching an API route to production, run through these six rules:
- Use plural nouns for collections:
/v1/ordersand/v1/orders/492, never/v1/getOrdersor/v1/createOrder. - Never leak stack traces: In production error handlers, return generic error messages (such as `Internal server error`) with a unique request ID (
X-Request-Id). Never expose raw SQL queries or file system paths to the client. - Filter with query parameters: Use query strings for filtering, sorting, and pagination (such as
/v1/users?role=admin&page=2&limit=20). - Always enforce JSON content types: Verify that incoming mutating requests contain
Content-Type: application/jsonbefore parsing bodies.
Building great REST APIs is about respect for the protocol. Use precise status codes, protect mutations with idempotency keys, rate limit aggressively, and your backend will run reliably under heavy traffic.
