Why Status Codes Matter
An HTTP status code is the first thing a client reads. Before it parses the body, before it checks headers — it reads the status. A well-chosen code tells the client exactly what happened: did it work, did the client do something wrong, did the server fail, or does the client need to go somewhere else?
When you return 200 OK with { "success": false } in the body, you've broken the protocol. The client's HTTP layer sees success, but the application layer sees failure. Now every consumer of your API has to parse the body just to know if the request worked. Libraries, monitoring tools, load balancers, and retry logic all depend on status codes being correct.
Status codes are grouped into five classes by their first digit. Each class has a clear semantic meaning — and staying within those semantics makes your API predictable to every developer, tool, and piece of infrastructure that touches it.
The five classes: 1xx Informational · 2xx Success · 3xx Redirection · 4xx Client Error · 5xx Server Error
This guide focuses on the codes you'll actually use — with Express examples, common mistakes, and the edge cases that trip up even experienced engineers.
The Most Common Mistakes
These patterns appear in production APIs constantly. Each one makes your API harder to consume, debug, and monitor.
200 OK for Everything
Returning { success: false, error: '...' } with a 200 status. The HTTP layer reports success. Load balancers don't alert. Retry logic doesn't trigger. Monitoring dashboards show green. The bug is invisible until a user reports it.
500 for Client Errors
A user sends a malformed request and your unhandled exception handler catches it and returns 500. Now the client thinks the server is broken and keeps retrying. Use proper validation and return 400 or 422 before the error ever reaches your exception handler.
404 for Validation Errors
Returning 404 when a required field is missing or a parameter is invalid. 404 means the URL doesn't exist. Validation errors are 400 or 422 — not 404. Clients expecting 400 won't handle a 404 from your validation logic.
No Error Detail in 4xx Responses
Returning { error: 'Bad request' } with no context. Which field? What value? What's expected? A good 400 response saves 30 minutes of debugging for the API consumer. Include field names, received values, and expected formats.
Using 200 for Empty Results
Returning { data: null } with 200 when a resource isn't found. If GET /users/999 returns 200, the client has no way to distinguish "user exists and is empty" from "user doesn't exist". Return 404.
4xx — Client Errors
The client did something wrong. The request was bad, unauthorized, forbidden, or conflicting. Don't return 5xx for client mistakes — that tells the client to retry, which won't help.
The request is malformed — missing fields, wrong types, invalid values. Always include a body explaining what is wrong. A 400 with no body forces the client to guess.
// Validation failed
res.status(400).json({
error: 'Validation failed',
fields: {
email: 'Invalid email format',
age: 'Must be a positive integer'
}
})
No valid authentication provided. The name is misleading — it means unauthenticated, not unauthorized. Include a WWW-Authenticate header indicating the auth scheme. The client should authenticate and retry.
// Missing or invalid token
res.status(401)
.header('WWW-Authenticate', 'Bearer')
.json({ error: 'Authentication required' })
Authenticated but not allowed. The server knows who you are, but you don't have permission to do this. Unlike 401, retrying with different credentials won't help — the action is simply not permitted for this identity.
// User authenticated but lacks permission
if (!user.roles.includes('admin')) {
return res.status(403).json({
error: 'Insufficient permissions'
})
}
The resource doesn't exist at this URL. Can also be used deliberately to hide the existence of a resource from unauthorized users — returning 404 instead of 403 to avoid leaking whether a resource exists.
const user = await User.findById(req.params.id)
if (!user) {
return res.status(404).json({ error: 'User not found' })
}
The request conflicts with the current state of the resource. Use this for duplicate key violations, concurrent edit conflicts, or when an idempotency lock is already held. Much more informative than a generic 400.
// Duplicate email on registration
try {
await User.create(req.body)
} catch (err) {
if (err.code === 11000) {
return res.status(409).json({
error: 'Email already registered'
})
}
}
The resource existed but has been permanently deleted. Unlike 404, this signals the client should remove any references to this URL. Useful for deprecated API endpoints or deleted content.
// Deprecated endpoint, permanently removed
res.status(410).json({
error: 'This endpoint has been removed',
alternative: '/api/v2/users'
})
The request is syntactically valid but semantically wrong. The body is well-formed JSON, but the business logic validation failed — e.g. end date before start date, insufficient balance. Use 400 for format errors, 422 for logic errors.
// Semantically invalid — end before start
if (req.body.endDate <= req.body.startDate) {
return res.status(422).json({
error: 'endDate must be after startDate'
})
}
Rate limit exceeded. Always include Retry-After and X-RateLimit-* headers so clients know when they can try again and how much quota they have left.
res.status(429)
.header('Retry-After', '60')
.header('X-RateLimit-Limit', '100')
.header('X-RateLimit-Remaining', '0')
.header('X-RateLimit-Reset', resetTimestamp)
.json({ error: 'Rate limit exceeded' })
5xx — Server Errors
Something went wrong on the server. The client did nothing wrong — the request was valid. These codes signal that retrying may help (with appropriate backoff).
An unexpected server-side error. The catch-all for unhandled exceptions. Never leak stack traces or internal details to the client — log them server-side, return a generic message. Always monitor 500s.
app.use((err, req, res, next) => {
console.error(err) // log full error internally
res.status(500).json({
error: 'An unexpected error occurred'
// never send err.message or err.stack
})
})
Your server is acting as a proxy/gateway and received an invalid response from an upstream service. Common when a microservice is down or returning garbage. Nginx returns this when your Node.js app crashes.
// Upstream service returned bad response
try {
const upstream = await callPaymentService()
} catch (err) {
res.status(502).json({ error: 'Payment service unavailable' })
}
The server is temporarily unavailable — overloaded or down for maintenance. Include a Retry-After header. Use this during graceful shutdowns, health check failures, or planned downtime.
// During graceful shutdown
process.on('SIGTERM', () => {
isShuttingDown = true
})
app.use((req, res, next) => {
if (isShuttingDown) {
return res.status(503)
.header('Retry-After', '10')
.json({ error: 'Server shutting down' })
}
next()
})
The gateway didn't receive a response from the upstream service in time. Different from 502 — the upstream is reachable but too slow. Common in long-running operations. Consider 202 Accepted + async processing instead.
// Set upstream timeout, return 504 on expiry
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), 5000)
try {
const data = await fetch(upstreamUrl, {
signal: controller.signal
})
} catch (err) {
if (err.name === 'AbortError') {
return res.status(504).json({ error: 'Upstream timeout' })
}
} finally {
clearTimeout(timeout)
}
5xx vs 4xx retry behaviour: 4xx errors should not be retried — the client needs to fix the request first. 5xx errors can be retried with exponential backoff and jitter, but only if the request is idempotent or protected by an idempotency key.
2xx — Success
The request was received, understood, and accepted. But not all successes are the same — using the right 2xx code tells the client exactly what happened.
The default success. Use it for GET and PUT responses when you're returning the resource. Don't use it for creation — that's what 201 is for.
// GET /users/1 — return the user res.status(200).json(user) // PUT /users/1 — return updated user res.status(200).json(updatedUser)
A new resource was created. Always use this for successful POST requests that create something. Include the created resource in the body and ideally a Location header pointing to it.
// POST /users — resource created
res.status(201)
.header('Location', `/users/${user.id}`)
.json(user)
The request was accepted for processing but hasn't been completed yet. Use this for async operations — background jobs, queue processing, email dispatch. The work will happen, just not right now.
// POST /reports/generate — queued async job
await queue.add('generate-report', payload)
res.status(202).json({ message: 'Report queued', jobId })
Success, but there's nothing to return. The canonical response for DELETE and for PUT/PATCH when you don't want to return the updated resource. Send no body — a body with 204 is invalid.
// DELETE /users/1 await User.findByIdAndDelete(req.params.id) res.status(204).send() // no body
Common mistake: Returning 200 for everything — including creates and deletes. Use 201 when something is created, 204 when there's nothing to return. Clients and monitoring tools depend on this distinction.
TL;DR
HTTP status code cheatsheet: 200 → success, returning data 201 → resource created (POST) 202 → accepted, processing async 204 → success, no body (DELETE) 301 → moved permanently (cache it) 304 → not modified, use your cache 307 → temp redirect, keep the method 400 → malformed request, fix your payload 401 → not authenticated 403 → authenticated but not allowed 404 → resource doesn't exist 409 → conflict with current state 422 → valid format, invalid logic 429 → slow down, rate limited 500 → unexpected server error (monitor this) 502 → upstream returned garbage 503 → server unavailable, retry later 504 → upstream too slow Rule: 4xx = client's fault, don't retry Rule: 5xx = server's fault, retry with backoff Rule: never return 200 with error in body
Status codes are the vocabulary of HTTP. Using them correctly makes your API self-documenting, debuggable, and compatible with every tool in the ecosystem — from load balancers to retry libraries to APM dashboards.
3xx — Redirection
The client needs to take further action to complete the request — usually go to a different URL. The right 3xx code determines whether the method changes and whether browsers cache the redirect.
The resource has permanently moved to a new URL. Browsers and crawlers cache this aggressively. The client may change a POST to GET on the redirected request. Use for permanent API version migrations or domain changes.
// Old endpoint permanently moved res.redirect(301, '/api/v2/users')
Temporary redirect. The resource is temporarily at a different URL but the original URL is still valid. Don't cache. Most clients change POST to GET on redirect — use 307 if you need to preserve the method.
// Temporary maintenance redirect res.redirect(302, '/maintenance')
The client's cached version is still current — don't re-download. Works with ETag and Last-Modified headers. Massive bandwidth saving for GET-heavy APIs and static assets.
const etag = generateETag(data)
if (req.headers['if-none-match'] === etag) {
return res.status(304).send()
}
res.setHeader('ETag', etag).json(data)
Temporary redirect that preserves the HTTP method. A POST stays a POST after the redirect. Use this instead of 302 when the method must not change — e.g. redirecting a form submission.
res.redirect(307, '/api/v2/payments')