Episode 3 — NodeJS MongoDB Backend Architecture / 3.9 — REST API Development
3.9.d — Status Codes in Practice
In one sentence: Choosing the right HTTP status code is not academic trivia — it tells clients exactly what happened, enables proper error handling, and makes your API predictable; this guide covers every status code you will use in production Express APIs.
Navigation: <- 3.9.c — Postman for API Testing | 3.9.e — Input Validation and Sanitization ->
1. The Big Picture
| Family | Meaning | Who is responsible? |
|---|---|---|
| 1xx | Informational | Server (rare in REST APIs) |
| 2xx | Success | Everything worked |
| 3xx | Redirection | Client needs to go elsewhere |
| 4xx | Client Error | Client sent a bad request |
| 5xx | Server Error | Server failed to handle a valid request |
Rule of thumb: If the client could fix the problem by changing the request, it is a 4xx. If the server is at fault, it is a 5xx.
2. 2xx — Success Patterns
200 OK — General success
The default "everything worked" code. Use for successful GET requests and for updates that return the modified resource.
// GET /api/users — list all users
app.get('/api/users', async (req, res) => {
const users = await User.find();
res.status(200).json({
data: users,
count: users.length
});
});
// PATCH /api/users/:id — update and return the user
app.patch('/api/users/:id', async (req, res) => {
const user = await User.findByIdAndUpdate(req.params.id, req.body, { new: true });
if (!user) return res.status(404).json({ error: { message: 'User not found' } });
res.status(200).json({ data: user });
});
201 Created — Resource created successfully
Use after a successful POST that creates a new resource. Best practice: include a Location header pointing to the new resource.
// POST /api/users — create a new user
app.post('/api/users', async (req, res) => {
const user = await User.create(req.body);
res
.status(201)
.location(`/api/users/${user._id}`)
.json({ data: user });
});
// Response headers will include:
// Location: /api/users/64abc123def456
204 No Content — Success with no response body
Use for successful DELETE operations or updates where the client does not need the response body.
// DELETE /api/users/:id
app.delete('/api/users/:id', async (req, res) => {
const user = await User.findByIdAndDelete(req.params.id);
if (!user) return res.status(404).json({ error: { message: 'User not found' } });
res.status(204).send(); // no body — use .send() not .json()
});
Important: Do not send a body with 204. Use .send() instead of .json().
Quick reference: which 2xx to use
| Operation | Status | Body? |
|---|---|---|
| GET success | 200 | Yes (the resource) |
| POST creates resource | 201 | Yes (the created resource) + Location header |
| PUT replaces resource | 200 | Yes (the replaced resource) |
| PATCH updates resource | 200 | Yes (the updated resource) |
| DELETE removes resource | 204 | No |
| Action endpoint (e.g., send email) | 200 or 202 | Yes (confirmation or job status) |
3. 4xx — Client Error Patterns
400 Bad Request — Malformed or invalid request
The catch-all for "your request is wrong." Use for: malformed JSON, missing required fields, invalid data types.
// Malformed JSON — Express handles this automatically
app.use(express.json());
// If client sends invalid JSON, Express returns 400 by default
// Manual validation
app.post('/api/users', (req, res) => {
const { name, email } = req.body;
if (!name || !email) {
return res.status(400).json({
error: {
code: 'VALIDATION_ERROR',
message: 'Missing required fields',
details: [
...(!name ? [{ field: 'name', message: 'Name is required' }] : []),
...(!email ? [{ field: 'email', message: 'Email is required' }] : [])
]
}
});
}
// ... create user
});
401 Unauthorized — Authentication failed or missing
The client is not authenticated. They either did not send credentials or the credentials are invalid.
// Auth middleware
const authenticate = (req, res, next) => {
const token = req.headers.authorization?.split(' ')[1];
if (!token) {
return res.status(401).json({
error: {
code: 'AUTH_REQUIRED',
message: 'Authentication required. Please provide a valid Bearer token.'
}
});
}
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
req.user = decoded;
next();
} catch (err) {
return res.status(401).json({
error: {
code: 'INVALID_TOKEN',
message: 'Token is invalid or expired'
}
});
}
};
403 Forbidden — Authenticated but not authorized
The server knows who you are but you do not have permission to perform this action.
// Authorization middleware
const requireRole = (...roles) => {
return (req, res, next) => {
if (!roles.includes(req.user.role)) {
return res.status(403).json({
error: {
code: 'FORBIDDEN',
message: `This action requires one of these roles: ${roles.join(', ')}`
}
});
}
next();
};
};
// Usage
app.delete('/api/users/:id', authenticate, requireRole('admin'), async (req, res) => {
await User.findByIdAndDelete(req.params.id);
res.status(204).send();
});
Key distinction:
- 401 = "Who are you?" (identity problem)
- 403 = "I know who you are, but you can't do this" (permission problem)
404 Not Found — Resource does not exist
app.get('/api/users/:id', async (req, res) => {
// Validate ObjectId format first to avoid Mongoose CastError
if (!mongoose.Types.ObjectId.isValid(req.params.id)) {
return res.status(400).json({
error: { code: 'INVALID_ID', message: 'Invalid user ID format' }
});
}
const user = await User.findById(req.params.id);
if (!user) {
return res.status(404).json({
error: { code: 'NOT_FOUND', message: `User with ID ${req.params.id} not found` }
});
}
res.json({ data: user });
});
409 Conflict — Duplicate or version conflict
app.post('/api/users', async (req, res) => {
const existing = await User.findOne({ email: req.body.email });
if (existing) {
return res.status(409).json({
error: {
code: 'DUPLICATE_RESOURCE',
message: 'A user with this email already exists',
details: { field: 'email', value: req.body.email }
}
});
}
// ... create user
});
422 Unprocessable Entity — Semantic validation failure
The JSON is well-formed (not a 400), but the data fails business rules.
app.post('/api/orders', async (req, res) => {
const { items, shippingAddress } = req.body;
// Syntax is fine, but business logic fails
const outOfStock = items.filter(item => item.quantity > item.available);
if (outOfStock.length > 0) {
return res.status(422).json({
error: {
code: 'UNPROCESSABLE_ENTITY',
message: 'Some items are out of stock',
details: outOfStock.map(item => ({
productId: item.productId,
requested: item.quantity,
available: item.available
}))
}
});
}
// ... process order
});
429 Too Many Requests — Rate limited
const rateLimit = require('express-rate-limit');
const limiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100,
standardHeaders: true, // Return rate limit info in RateLimit-* headers
legacyHeaders: false,
message: {
error: {
code: 'RATE_LIMITED',
message: 'Too many requests. Please try again later.',
retryAfter: 900 // seconds
}
}
});
app.use('/api/', limiter);
Response headers from express-rate-limit:
RateLimit-Limit: 100
RateLimit-Remaining: 0
RateLimit-Reset: 1714567890
Retry-After: 900
4. 5xx — Server Error Patterns
500 Internal Server Error — Unhandled exception
// Global error handler — catches all unhandled errors
app.use((err, req, res, next) => {
console.error('Unhandled error:', err.stack);
// Never expose internal error details in production
const isDev = process.env.NODE_ENV === 'development';
res.status(500).json({
error: {
code: 'INTERNAL_ERROR',
message: isDev ? err.message : 'Something went wrong. Please try again later.',
...(isDev && { stack: err.stack })
}
});
});
502 Bad Gateway — Upstream service is down
When your API depends on another service that fails:
app.get('/api/weather', async (req, res) => {
try {
const response = await fetch('https://weather-api.example.com/current');
if (!response.ok) throw new Error('Weather API returned error');
const data = await response.json();
res.json({ data });
} catch (err) {
res.status(502).json({
error: {
code: 'BAD_GATEWAY',
message: 'Weather service is currently unavailable'
}
});
}
});
503 Service Unavailable — Maintenance or overloaded
// Maintenance mode middleware
const maintenanceMode = (req, res, next) => {
if (process.env.MAINTENANCE_MODE === 'true') {
return res.status(503)
.set('Retry-After', '3600') // try again in 1 hour
.json({
error: {
code: 'SERVICE_UNAVAILABLE',
message: 'API is under maintenance. Please try again later.',
retryAfter: 3600
}
});
}
next();
};
app.use(maintenanceMode);
5. Status Code Decision Flowchart
Request arrives
|
+--> Is the request well-formed? (valid JSON, correct Content-Type)
| NO --> 400 Bad Request
|
+--> Is the client authenticated?
| NO --> 401 Unauthorized
|
+--> Is the client authorized for this action?
| NO --> 403 Forbidden
|
+--> Does the requested resource exist?
| NO --> 404 Not Found
|
+--> Is the client rate-limited?
| YES --> 429 Too Many Requests
|
+--> Does the request pass validation?
| NO --> 422 Unprocessable Entity (or 400)
|
+--> Does the request conflict with existing data?
| YES --> 409 Conflict
|
+--> Can the server process this successfully?
| NO --> 500 Internal Server Error
| (or 502 if upstream, 503 if overloaded)
|
+--> SUCCESS:
GET --> 200 OK
POST --> 201 Created
PUT --> 200 OK
PATCH --> 200 OK
DELETE --> 204 No Content
6. Consistent Error Response Format
Every error response in your API should follow one consistent shape. This makes client-side error handling predictable.
Standard error envelope
// Consistent error shape across your entire API
{
"error": {
"code": "VALIDATION_ERROR", // machine-readable error code
"message": "Email is required", // human-readable message
"details": [ // optional: field-level errors
{
"field": "email",
"message": "Email is required",
"type": "required"
}
]
}
}
Error response utility
// src/utils/apiError.js
class ApiError extends Error {
constructor(statusCode, code, message, details = null) {
super(message);
this.statusCode = statusCode;
this.code = code;
this.details = details;
}
static badRequest(message, details) {
return new ApiError(400, 'BAD_REQUEST', message, details);
}
static unauthorized(message = 'Authentication required') {
return new ApiError(401, 'UNAUTHORIZED', message);
}
static forbidden(message = 'You do not have permission') {
return new ApiError(403, 'FORBIDDEN', message);
}
static notFound(resource = 'Resource') {
return new ApiError(404, 'NOT_FOUND', `${resource} not found`);
}
static conflict(message, details) {
return new ApiError(409, 'CONFLICT', message, details);
}
static tooMany(retryAfter = 900) {
return new ApiError(429, 'RATE_LIMITED', 'Too many requests', { retryAfter });
}
static internal(message = 'Internal server error') {
return new ApiError(500, 'INTERNAL_ERROR', message);
}
}
module.exports = ApiError;
Global error handler using ApiError
// src/middleware/errorHandler.js
const errorHandler = (err, req, res, next) => {
// Handle our custom ApiError
if (err.statusCode) {
return res.status(err.statusCode).json({
error: {
code: err.code,
message: err.message,
...(err.details && { details: err.details })
}
});
}
// Handle Mongoose validation errors
if (err.name === 'ValidationError') {
const details = Object.values(err.errors).map(e => ({
field: e.path,
message: e.message,
type: e.kind
}));
return res.status(400).json({
error: { code: 'VALIDATION_ERROR', message: 'Validation failed', details }
});
}
// Handle Mongoose duplicate key
if (err.code === 11000) {
const field = Object.keys(err.keyValue)[0];
return res.status(409).json({
error: {
code: 'DUPLICATE_KEY',
message: `Duplicate value for ${field}`,
details: { field, value: err.keyValue[field] }
}
});
}
// Fallback for unknown errors
console.error('Unhandled error:', err);
res.status(500).json({
error: {
code: 'INTERNAL_ERROR',
message: process.env.NODE_ENV === 'development' ? err.message : 'Something went wrong'
}
});
};
module.exports = errorHandler;
Using ApiError in routes
const ApiError = require('../utils/apiError');
app.get('/api/users/:id', async (req, res, next) => {
try {
const user = await User.findById(req.params.id);
if (!user) throw ApiError.notFound('User');
res.json({ data: user });
} catch (err) {
next(err); // pass to global error handler
}
});
7. Key Takeaways
- Pick the most specific status code — 409 for duplicates, 422 for business-rule failures, not just 400 for everything.
- 401 vs 403: 401 is "who are you?" (authentication), 403 is "you can't do this" (authorization).
- 201 + Location header for created resources; 204 with no body for deletes.
- Never expose stack traces in production — use a global error handler that sanitizes 5xx errors.
- Build a consistent error envelope (
{ error: { code, message, details } }) and use it everywhere.
Explain-It Challenge
Explain without notes:
- Walk through the status code decision flowchart for a POST request that tries to create a user with a duplicate email.
- Why should you return 422 instead of 400 when the JSON is valid but the data violates a business rule?
- How does an
ApiErrorutility class improve consistency across a large Express application?
Navigation: <- 3.9.c — Postman for API Testing | 3.9.e — Input Validation and Sanitization ->