Every API interaction is a conversation. Your client sends a request, and the server responds — not just with data, but with a status code that tells the client exactly what happened. Understanding these codes is fundamental to building robust APIs and resilient clients.
Without proper status codes, debugging becomes guesswork. Did the request fail because of bad input? Authentication issues? A server crash? The right status code answers these questions instantly, saving developers hours of troubleshooting and enabling clients to handle errors gracefully.
2xx Success Codes
Success codes confirm that the server received, understood, and processed the request.
200 OK
The standard success response. Use it when returning data — a fetched resource, search results, or the outcome of an operation.
GET /api/users/42 → 200 OK
{ "id": 42, "name": "Alice", "email": "alice@example.com" }
201 Created
Use this when a new resource has been created. Always pair it with a Location header pointing to the new resource.
POST /api/users → 201 Created
Location: /api/users/43
{ "id": 43, "name": "Bob" }
204 No Content
The request succeeded, but there's nothing to send back. Perfect for DELETE operations or updates where the client doesn't need the modified resource returned.
DELETE /api/users/42 → 204 No Content
(empty body)
When to choose which: If you're returning data, use 200. If you created something, use 201. If the action succeeded but there's no meaningful body, use 204.
3xx Redirection Codes
Redirection codes are less common in APIs than in web browsers, but they still have important uses.
301 Moved Permanently
The resource has a new permanent URL. Clients and intermediaries should update their bookmarks. Use this when you've restructured your API and want to guide clients to the new endpoint.
302 Found (Temporary Redirect)
The resource is temporarily at a different URL. The client should continue using the original URL for future requests. Useful for temporary maintenance redirects or OAuth flows.
304 Not Modified
A powerful caching tool. When a client sends a conditional request with If-None-Match or If-Modified-Since headers, and the resource hasn't changed, respond with 304. The client uses its cached copy, saving bandwidth and processing time.
GET /api/products/99
If-None-Match: "etag-abc123"
→ 304 Not Modified
4xx Client Error Codes
These indicate the client did something wrong. The request should be modified before retrying.
400 Bad Request
The server can't process the request due to malformed syntax — invalid JSON, missing required fields, or wrong data types.
401 Unauthorized
Authentication is missing or invalid. The client needs to provide valid credentials. Despite the name, this is about authentication, not authorization.
403 Forbidden
The client is authenticated but doesn't have permission to access this resource. Unlike 401, re-authenticating won't help — the user simply lacks the required privileges.
404 Not Found
The requested resource doesn't exist. This is also commonly used to hide the existence of resources from unauthorized users (instead of returning 403).
409 Conflict
The request conflicts with the current state of the resource. Common scenarios include duplicate entries, version conflicts in optimistic locking, or trying to delete a resource that has dependencies.
422 Unprocessable Entity
The syntax is valid, but the content is semantically wrong. Think of it as "I understand your JSON, but the data doesn't make sense" — like a negative age or an end date before a start date.
429 Too Many Requests
The client has hit a rate limit. Always include a Retry-After header so the client knows when to try again.
429 Too Many Requests
Retry-After: 60
{ "error": "rate_limit_exceeded", "message": "Try again in 60 seconds" }
5xx Server Error Codes
These indicate something went wrong on the server side. The client's request may have been valid.
500 Internal Server Error
The catch-all for unexpected server failures — unhandled exceptions, null pointer errors, or configuration issues. If you're seeing lots of 500s, something needs fixing on the backend.
502 Bad Gateway
The server, acting as a gateway or proxy, received an invalid response from an upstream server. Common in microservice architectures when a downstream service is misbehaving.
503 Service Unavailable
The server is temporarily unable to handle requests — typically due to maintenance or overload. Like 429, include a Retry-After header when possible.
Designing Proper Error Responses
A status code alone isn't enough. Your error responses should be structured and consistent:
{
"error": {
"code": "validation_error",
"message": "The request body contains invalid fields.",
"details": [
{
"field": "email",
"issue": "Invalid email format"
},
{
"field": "age",
"issue": "Must be a positive integer"
}
]
}
}
Key principles:
-
Machine-readable error code — clients can switch on
error.codewithout parsing messages - Human-readable message — helpful for debugging
- Details array — specific field-level errors for validation failures
- Consistent structure — every error follows the same format
Server-Side: Express.js Error Handling Middleware
Here's a practical error handling middleware that maps custom errors to proper HTTP responses:
class AppError extends Error {
constructor(statusCode, code, message, details = null) {
super(message);
this.statusCode = statusCode;
this.code = code;
this.details = details;
}
}
// Error handling middleware (must have 4 parameters)
function errorHandler(err, req, res, next) {
if (err instanceof AppError) {
return res.status(err.statusCode).json({
error: {
code: err.code,
message: err.message,
...(err.details && { details: err.details }),
},
});
}
// Unexpected errors → 500
console.error('Unhandled error:', err);
res.status(500).json({
error: {
code: 'internal_error',
message: 'An unexpected error occurred.',
},
});
}
// Usage in routes
app.post('/api/users', (req, res) => {
if (!req.body.email) {
throw new AppError(422, 'validation_error', 'Missing required fields', [
{ field: 'email', issue: 'Email is required' },
]);
}
// ... create user
});
app.use(errorHandler);
Client-Side: Handling Status Codes with Fetch
On the client side, handle different status codes explicitly rather than treating all errors the same:
async function apiRequest(url, options = {}) {
const response = await fetch(url, {
...options,
headers: {
'Content-Type': 'application/json',
...options.headers,
},
});
if (response.ok) {
// 204 has no body
if (response.status === 204) return null;
return response.json();
}
const error = await response.json().catch(() => ({}));
switch (response.status) {
case 401:
// Redirect to login or refresh token
window.location.href = '/login';
break;
case 403:
throw new Error('You do not have permission for this action.');
case 404:
throw new Error('The requested resource was not found.');
case 422:
// Return structured validation errors for form display
throw { type: 'validation', details: error.error?.details || [] };
case 429:
// Retry after the specified delay
const retryAfter = response.headers.get('Retry-After') || 60;
await new Promise((r) => setTimeout(r, retryAfter * 1000));
return apiRequest(url, options);
default:
throw new Error(error.error?.message || 'Something went wrong.');
}
}
Wrapping Up
HTTP status codes are the universal language of API communication. Use them correctly, and your API becomes self-documenting — clients know exactly what happened and what to do next. Pair meaningful status codes with structured error responses, and you'll build APIs that developers genuinely enjoy working with.
The golden rule: be specific. A 422 with field-level details is infinitely more useful than a generic 400 with "Bad Request." Your future self — and every developer consuming your API — will thank you.
Published by 1xAPI
Top comments (0)