HTTP Status Codes Are Not Suggestions. Use Them Correctly.
by Eric Hanson, Backend Developer at Clean Systems Consulting
When everything is 200, nothing is meaningful
If your API returns 200 OK for almost everything, you’ve effectively disabled one of the most important parts of HTTP.
This is more common than teams admit:
- validation errors returning
200 - permission issues returning
200 - partial failures hidden inside a “successful” response
It works—until it doesn’t.
Clients start layering their own logic to interpret responses. Monitoring becomes unreliable. Retries don’t behave as expected. You’ve replaced a well-defined protocol with a custom one that every consumer has to relearn.
HTTP status codes are not decoration. They are part of the contract.
Status codes drive behavior, not just meaning
The biggest misunderstanding is thinking status codes are only descriptive.
They’re not. They actively control behavior across the system:
- HTTP clients decide whether to retry based on them
- proxies and CDNs cache based on them
- load balancers track health using them
- observability tools aggregate them into error rates
If you misuse them, you’re not just confusing humans—you’re breaking infrastructure.
The minimal set you actually need
You don’t need to memorize the entire RFC. Most APIs can operate correctly with a small, consistent subset.
Success (2xx)
200 OK→ standard success with a response body201 Created→ resource successfully created204 No Content→ success with no response body (e.g., delete)
Example:
HTTP/1.1 204 No Content
No JSON payload needed.
Client errors (4xx)
These indicate the client did something wrong.
400 Bad Request→ malformed or invalid input401 Unauthorized→ missing/invalid authentication403 Forbidden→ authenticated but not allowed404 Not Found→ resource doesn’t exist409 Conflict→ state conflict (duplicate, version mismatch)422 Unprocessable Entity→ valid structure, invalid semantics
Example:
HTTP/1.1 422 Unprocessable Entity
{
"error": {
"code": "INVALID_QUANTITY",
"message": "Quantity must be greater than 0"
}
}
Server errors (5xx)
These mean your system failed to handle a valid request.
500 Internal Server Error→ generic failure502 Bad Gateway→ upstream dependency failed503 Service Unavailable→ temporary overload or maintenance
These should trigger alerts. If they don’t, your monitoring is broken.
Common misuse patterns (and why they hurt)
Using 400 for everything
HTTP/1.1 400 Bad Request
for:
- missing auth
- invalid permissions
- resource not found
This flattens all client errors into one category. Clients can’t respond intelligently.
A 404 might be handled silently. A 401 might trigger re-authentication. A 403 might show a permission error.
You’ve removed that nuance.
Returning 500 for client mistakes
If a user submits invalid data and you return 500, you’re blaming the system for a client issue.
This:
- pollutes error metrics
- triggers unnecessary alerts
- hides real server problems
Returning 200 with embedded errors
{
"success": false,
"error": "Something failed"
}
This breaks:
- retry mechanisms
- caching behavior
- monitoring accuracy
You’ve effectively opted out of HTTP.
Overusing 204
204 No Content is useful, but overusing it removes useful feedback.
Example:
DELETE /orders/123
Returning 204 is fine.
But for operations where clients benefit from confirmation data, returning 200 with a body is often better:
{
"status": "cancelled",
"cancelled_at": "2026-04-19T10:00:00Z"
}
Status codes and idempotency
Status codes also interact with idempotency (whether repeating a request produces the same result).
Example:
DELETE /orders/123
First request:
204 No Content
Second request:
404 Not Found
Both are correct. But they signal different states:
- first → resource existed and was deleted
- second → resource is already gone
Clients can use this to decide whether to retry or ignore.
If both returned 200, that signal disappears.
Make status codes predictable
The real goal is not perfection. It’s predictability.
For a given type of operation, clients should know what to expect.
Example:
- create →
201or409 - fetch →
200or404 - update →
200/204or404/422 - delete →
204or404
If different endpoints handle similar situations differently, you’ve created inconsistency at the protocol level.
Testing and enforcement
If you don’t enforce status code usage, it will drift.
Add checks at multiple levels:
Contract tests
expect(response.status).toBe(422)
Not just payload validation.
OpenAPI definitions
Explicitly define responses:
responses:
'200':
description: Success
'404':
description: Resource not found
'422':
description: Validation error
Linting
Use tools like Spectral to enforce:
- allowed status codes per operation
- consistency across endpoints
This turns conventions into guarantees.
The tradeoff: strictness vs speed
Being precise with status codes takes effort:
- developers need to think through edge cases
- more branches in handler logic
- more detailed tests
But skipping that work creates hidden costs:
- unreliable retries
- misleading metrics
- fragile client integrations
You’re either explicit in your API design, or implicit in your bugs.
What to do differently this week
Pick one endpoint and map out all its outcomes:
- success
- validation failure
- not found
- permission issue
- server failure
Assign the correct status code to each.
Then update the implementation and tests to enforce it.
It’s a small change, but it immediately improves how your system behaves under real conditions.