HTTP status codes are a contract between your API and every system that calls it — frontend clients, monitoring tools, load balancers, retry logic, search engine crawlers, and API consumers. Returning the wrong status code does not just look wrong. It actively breaks things: a 200 OK on an error body disables retry mechanisms, a 404 instead of a 410 delays SEO deindexing, a 401 instead of a 403 tells clients to keep trying to authenticate when the problem is permissions. This is a decision framework for choosing the right code — not a list of definitions the internet already has.
Most commonly misused production API responses
Most API bugs are not business logic bugs. They are response-contract bugs.
In production API reviews, I repeatedly see teams spend weeks discussing architecture, authentication, caching, and deployment pipelines — then return 200 OK with a JSON error body for half their failure states. Wrong HTTP status codes do not just make APIs untidy. They break retries, confuse frontend state handling, hide operational failures from monitoring, and force every consuming client to guess what actually happened.
I have reviewed hundreds of API implementations over fifteen years. The status code handling is almost always the most visible sign of an engineer's production experience. Junior developers return 200 for everything and put "success": false in the body. Mid-level developers know the common codes but confuse 401/403 and 400/422. Senior developers understand that status codes are not metadata — they are the primary communication channel between their API and every system that depends on it.
HTTP Response Code Reality in Modern APIs
| Pattern | Why It Is Dangerous |
|---|---|
200 OK used for validation failures | Clients cannot distinguish success from failure — retries disabled, monitoring silent |
500 returned for user mistakes | Monitoring fills with false server incidents, obscures real application bugs |
401 and 403 used interchangeably | Auth flows become inconsistent — login loops or wrong UI state shown to users |
404 used for business rule conflicts | Frontend retry logic breaks — 404 implies "not found," not "already exists" |
Why HTTP Status Codes Are an API Contract, Not Decoration
I found 17 endpoints returning HTTP 200 for complete business failures because the backend team had standardised every response into {"success": false}. Monitoring dashboards showed green. Client SDKs treated failures as success. Retry logic never triggered. The API was technically alive and operationally broken — and nobody knew, because the infrastructure had no way to tell.
When your API returns a status code, you are not annotating a response for a human reader. You are making a machine-readable commitment to every system in your stack about what happened and what they should do next.
Frontend clients depend on them. A React application that receives a 401 should redirect to the login page. A 403 should show an access denied message. A 422 should display field-level validation errors. A 200 OK with {"success": false} gives the frontend nothing actionable — it must parse, interpret, and guess what the right UX response is. Multiply that guesswork across every endpoint your frontend calls and you have an integration that is fragile by design.
API gateways and load balancers depend on them. Kong, AWS API Gateway, NGINX, and every enterprise load balancer make routing, health-checking, and circuit-breaking decisions based on status codes. A node returning consistent 200 OK responses is treated as healthy — even if every response body contains an error. A node returning 503 gets removed from the rotation. Wrong status codes make your infrastructure blind to failure.
Retry libraries depend on them. HTTP clients — Axios, Feign, Spring WebClient, OkHttp — implement retry logic based on status codes. 5xx responses and 429 with Retry-After are retried. 4xx responses are not. An error masked as 200 OK is never retried by any standard HTTP client library. An unretriable request becomes a permanent failure that nobody diagnoses because the error rate dashboard shows zero.
Observability depends on them. Datadog, Prometheus, Grafana, CloudWatch — all surface error rate metrics broken down by status code family. A query for status:5xx AND service:payment-api returns your incidents in seconds. With 200-for-everything, that query returns nothing. The incidents are there, buried in body content that your monitoring infrastructure was never designed to parse. The absence of 5xx alerts is not stability — it is blindness.
Why Wrong Status Codes Break Frontend Retries, Monitoring, and SDKs
The downstream impact is more specific than most engineers realise when they are writing the code:
- Axios interceptors — the standard
axios.interceptors.responsepattern branches on 401 to trigger token refresh and on 403 to redirect to an error page. A 403 returned for a missing token sends the user to "access denied" instead of the login screen. An interceptor cannot distinguish these without reading the body — which defeats the purpose of HTTP semantics entirely. - Retry libraries — Spring WebClient, OkHttp, Feign, and Resilience4j retry configurations target 5xx and 429 responses. A transient failure returned as 200 OK is never retried. A rate limit returned as 500 is retried immediately, causing the thundering herd problem the 429 + Retry-After pattern was designed to prevent.
- CDN and reverse proxy caching — Cloudflare, Fastly, and Varnish cache responses based on status code. 200 responses are cached. 404 and 410 responses are not (or are cached briefly). A resource that returns 200 with empty content will be cached and served from edge nodes long after the real resource is available — causing stale content that is invisible to your origin server.
- Kubernetes liveness and readiness probes —
httpGetprobes interpret any 2xx as healthy. A service returning 200 OK while unable to reach its database appears healthy to Kubernetes and continues receiving traffic. The correct response when a critical dependency is unavailable is 503 — which causes the probe to fail and the pod to be removed from the endpoint slice. - Mobile SDK clients — iOS and Android SDK implementations branch on status code family (2xx / 4xx / 5xx) before parsing the body. Wrong codes produce wrong SDK-level error types, which means the error handling code the mobile developer wrote for authentication failures will never fire on a 200 OK response — even when the body contains an auth error.
The diagram below shows what a single wrong status code silently breaks across six systems simultaneously — all of which took the 200 OK at face value.
"HTTP status codes are not decoration. They are machine-readable operational truth."
The Senior Developer HTTP Status Decision Tree
Before choosing a status code, answer these questions in order:
"HTTP status codes are not decoration. They are machine-readable operational truth — the first thing every client, gateway, and monitoring system reads before it decides what to do next."
Success Responses — 200 vs 201 vs 202 vs 204
The 2xx family is not just "it worked." Each code tells the client something specific about what happened — and those specifics matter to consumers building retry logic, polling mechanisms, and UI flows.
200 OK
The request completed successfully and a body is returned. Use for GET reads, successful PUT updates, and synchronous operations that return data.
- GET /users/42 → user data returned
- PUT /users/42 → updated user returned
201 Created
A new resource was created. Always include a Location header pointing to the new resource URI. Use for POST operations that create entities.
- POST /users → 201 + Location: /users/42
- POST /orders → 201 + Location: /orders/91
202 Accepted
Request received, processing is queued or async. Return a job ID or status URL. The client polls for completion — do not use 202 if processing completes in the same request.
- POST /reports/generate → 202 + jobId
- POST /emails/send-bulk → 202 + trackingUrl
204 No Content
Success, no body returned. Use for DELETE operations and updates where returning the updated resource would be redundant. Never include a response body with 204.
- DELETE /users/42 → 204
- PUT /users/42/preferences → 204
Spring Boot · correct 2xx responses
// GET — return data with 200
@GetMapping("/users/{id}")
public ResponseEntity<UserDto> getUser(@PathVariable Long id) {
return ResponseEntity.ok(userService.findById(id)); // 200
}
// POST — return 201 with Location header
@PostMapping("/users")
public ResponseEntity<UserDto> createUser(@RequestBody @Valid CreateUserRequest req,
UriComponentsBuilder uriBuilder) {
UserDto created = userService.create(req);
URI location = uriBuilder.path("/users/{id}").buildAndExpand(created.id()).toUri();
return ResponseEntity.created(location).body(created); // 201 + Location header
}
// DELETE — return 204, no body
@DeleteMapping("/users/{id}")
public ResponseEntity<Void> deleteUser(@PathVariable Long id) {
userService.delete(id);
return ResponseEntity.noContent().build(); // 204
}
Authentication & Permission — 401 vs 403
This is the most consistently confused pair in API development. I see them swapped in every third codebase I review. The distinction is precise and operationally important — because clients use these codes to decide whether to re-authenticate.
401 Unauthorized — "Who are you?"
No valid credentials were provided. The client should authenticate and retry. The response must include a WWW-Authenticate header describing the authentication scheme.
- Missing or expired JWT token
- Invalid API key
- Session has expired
- Client should redirect to login
403 Forbidden — "I know who you are. No."
Valid credentials, insufficient permissions. Re-authenticating will not help. The client should not retry — the user simply does not have access to this resource.
- User authenticated but lacks ADMIN role
- User tries to access another user's data (BOLA)
- Valid token but wrong scope for this endpoint
- Client should show an "access denied" message, not a login screen
Returning 403 when you mean 401 causes frontend clients to show "access denied" when they should redirect to login. Returning 401 when you mean 403 triggers an infinite auth loop — the user authenticates successfully, gets a new token, and hits 401 again because the problem was never credentials. Both break user experience and integration logic in ways that are difficult to debug without reading the HTTP spec carefully.
// BAD — 403 when token is completely missing
if (!hasPermission(user)) {
return ResponseEntity.status(403).build(); // wrong when user is null
}
// If user is null (unauthenticated), this should be 401, not 403
Check authentication first, authorisation second. A null user is a 401. An authenticated user without the required role is a 403. The order matters — and so does the code.
Spring Boot · correct 401 vs 403 in SecurityFilterChain
http
// 401 — no valid credentials → redirect to authenticate
.exceptionHandling(ex -> ex
.authenticationEntryPoint((req, res, authEx) -> {
res.setHeader("WWW-Authenticate", "Bearer realm=\"api\"");
res.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Authentication required");
})
// 403 — authenticated but no permission → show access denied
.accessDeniedHandler((req, res, accessEx) -> {
res.sendError(HttpServletResponse.SC_FORBIDDEN, "Insufficient permissions");
})
);
JWT Auth Failures − Differentiating Expired, Invalid, and Missing
When a client receives a 401 Unauthorized, the immediate question for the frontend is: "Do I need to login again, or is my token simply expired and needs refreshing?". Bespoke error formats often force the client to parse string messages like "Token is expired", which is fragile and error-prone.
RFC 9457 allows you to use distinct type URIs to communicate these states machine-readably, enabling your authorization interceptors to act decisively.
✗ Ambiguous 401
{
"type": "about:blank",
"title": "Unauthorized",
"status": 401,
"detail": "Token expired"
}✓ Distinct Problem Types
{
"type": "https://api.yourdomain.com/probs/token-expired",
"title": "Token Expired",
"status": 401,
"detail": "Credential expired at 2026-05-01T12:00:00Z",
"expiredAt": "2026-05-01T12:00:00Z"
}This allows your Axios/Fetch interceptors to branch logic based on the type field. If the type is token-expired, you can automatically trigger a POST /auth/refresh call; if it's invalid-token or missing-token, you redirect to the login page immediately. No string parsing required.
RFC 9110 requires a 401 response to include a WWW-Authenticate header. Combine this with RFC 9457 to provide both the protocol-level requirement and the application-level detail.
Missing vs Gone vs Conflict — 404 vs 410 vs 409
404 Not Found
Resource does not currently exist. May have never existed or may exist in future. Search engines will continue to check periodically.
- User ID that does not exist
- Product slug that has not been published yet
- Temporary absence — may return
410 Gone
Resource existed and has been permanently deleted. Search engines will deindex faster on 410 than 404. Use it when you intentionally retire a URL.
- Blog post permanently deleted
- Deprecated API version endpoint
- Product removed from catalogue
Google treats 410 Gone as a stronger signal than 404 Not Found. A 410 tells Googlebot to remove the URL from the index immediately on the next crawl. A 404 gets a grace period — Google may continue checking for several weeks before deindexing. If you intentionally remove a published page or API endpoint, use 410. It matters for SEO and for API consumers who cache resource states.
409 Conflict — state collision
The request is valid and the user is authorised, but the current state of the resource prevents the operation from completing. The client needs to resolve the conflict before retrying.
- Creating a user with an email that already exists
- Trying to publish a draft that is locked by another editor
- Optimistic locking conflict — resource was modified since last read
- Trying to cancel an order that is already shipped
Validation Failures — 400 vs 409 vs 422
All three deal with "something is wrong with the request" — but they describe different kinds of wrong, and clients need to respond differently to each.
400 Bad Request
The request itself is malformed — invalid JSON syntax, missing required headers, wrong Content-Type, or an unrecognisable structure. The server could not even parse the request body.
422 Unprocessable Entity
The request was parsed successfully but failed semantic validation. The content is syntactically valid but logically wrong — an email field that is valid JSON but not a valid email address.
Spring Boot · structured validation error responses
@RestControllerAdvice
public class ApiExceptionHandler {
// 400 — malformed request (JSON parse failure, wrong Content-Type)
@ExceptionHandler(HttpMessageNotReadableException.class)
public ResponseEntity<ErrorResponse> handleMalformed(HttpMessageNotReadableException ex) {
return ResponseEntity.badRequest() // 400
.body(new ErrorResponse("MALFORMED_REQUEST", "Request body could not be parsed"));
}
// 422 — validation failure on well-formed request
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ValidationErrorResponse> handleValidation(
MethodArgumentNotValidException ex) {
Map<String, String> fieldErrors = ex.getBindingResult().getFieldErrors()
.stream()
.collect(Collectors.toMap(FieldError::getField, FieldError::getDefaultMessage));
return ResponseEntity.unprocessableEntity() // 422
.body(new ValidationErrorResponse("VALIDATION_FAILED", fieldErrors));
}
// 409 — state conflict
@ExceptionHandler(ResourceConflictException.class)
public ResponseEntity<ErrorResponse> handleConflict(ResourceConflictException ex) {
return ResponseEntity.status(HttpStatus.CONFLICT) // 409
.body(new ErrorResponse("CONFLICT", ex.getMessage()));
}
}
Validation Errors − Field-Level Detail as RFC 9457 Extension
One of the most frequent questions I get in workshops is: "Where do the field-level errors go if they aren't in the standard?". RFC 9457 is intentionally minimal. It defines the core wrapper but encourages the use of Extension Members for problem-specific details. For validation, the industry standard has converged on adding an errors array of objects.
While many legacy APIs use 400 Bad Request for everything, senior developers prefer 422 for validation. It signals that the request was syntactically correct (valid JSON/XML) but semantically invalid (invalid field values). This distinction allows clients to separate "You sent me garbage I can't parse" from "I parsed your data, but your email is invalid."
Here is how a production-grade validation extension looks. Notice we include a field and a machine-readable reason alongside the human message:
{
"type": "https://api.yourdomain.com/probs/validation-error",
"title": "Validation Failed",
"status": 422,
"detail": "The request contained 2 validation errors",
"instance": "/logs/abc-123",
"errors": [
{
"field": "email",
"reason": "INVALID_FORMAT",
"message": "must be a valid email address"
},
{
"field": "age",
"reason": "MIN_VALUE",
"message": "must be 18 or older"
}
]
}reason code (like INVALID_FORMAT) allows your frontend to handle internationalization (i18n) locally. Instead of relying on the backend's English string, the frontend looks up the translation key for INVALID_FORMAT.Rate Limits and Temporary Failures — 429 vs 503
429 Too Many Requests
Client has exceeded the rate limit. Always include a Retry-After header indicating when the client can next attempt the request. The client should back off and respect the header.
503 Service Unavailable
The server is temporarily unable to handle requests — overloaded, in maintenance mode, or a critical dependency is down. Also include Retry-After if the maintenance window is known.
Spring Boot · 429 with Retry-After header
// Rate limiter fallback — return 429 with Retry-After, never 500
public ResponseEntity<?> rateLimitFallback(RequestNotPermitted e) {
return ResponseEntity
.status(HttpStatus.TOO_MANY_REQUESTS) // 429
.header("Retry-After", "60") // seconds until retry is allowed
.header("X-RateLimit-Limit", "100")
.header("X-RateLimit-Reset", String.valueOf(Instant.now().plusSeconds(60).getEpochSecond()))
.body(Map.of(
"error", "RATE_LIMIT_EXCEEDED",
"message", "Too many requests. Retry after 60 seconds.",
"retryAfter", 60
));
}
Without Retry-After, a client that receives 429 has no information about when to retry. Well-implemented HTTP clients will use exponential backoff — which means the retry storm arrives in waves. A Retry-After header coordinates all clients to retry at the same time window, preventing thundering herd problems after a rate limit expires.
Downstream Service Failures − 503 with Retry Guidance
In microservices, your API is often a orchestrator. When a downstream service (like a payment gateway, database, or a third-party legacy system) is unavailable, returning a generic 500 is an operational lie. It implies your code crashed and alerts your on-call engineer for the wrong reason.
Returning a 503 Service Unavailable with a retryAfter extension member tells the client and the infrastructure exactly how to handle the transient failure.
{
"type": "https://api.yourdomain.com/probs/service-unavailable",
"title": "Service Unavailable",
"status": 503,
"detail": "The Payment Provider is undergoing scheduled maintenance.",
"retryAfter": 30,
"downstreamService": "Stripe"
}By including retryAfter as an extension (and ideally as a Retry-After HTTP header), you allow automated retry libraries like Resilience4j or Polly to wait for the specified window. This prevents the "thundering herd" effect where thousands of clients retry immediately after a blip, crashing your system just as it tries to recover.
When your monitoring shows a spike in 503s specifically with type service-unavailable, you know the issue is external capacity or maintenance. If you see 500s, you know you have a regression in your own code to fix.
"In microservices, the difference between 500, 502, 503, and 504 is not semantic pedantry. It is the difference between your on-call engineer looking at application logs, gateway logs, upstream service health, or timeout configuration."
Server Failures — 500 vs 502 vs 503 vs 504
The 5xx family covers everything where the server is at fault, not the client. The specific code tells operators and monitoring systems where to look — in your application, in your gateway, or in your upstream dependencies.
500 Internal Server Error
Unhandled exception in your application code. An unexpected condition prevented the request from completing. This is your bug — log it, alert on it, fix it.
502 Bad Gateway
Your gateway or proxy received an invalid, unexpected, or error response from an upstream server. The gateway is working; the backend returned something it could not use.
503 Service Unavailable
The server cannot handle requests right now — overloaded or in maintenance. Temporary. Use with Retry-After. Load balancers will stop routing new requests when they see repeated 503s.
504 Gateway Timeout
The gateway did not receive a response from an upstream server within the configured timeout. The upstream may be slow, overloaded, or hanging. Distinct from 503 — the connection was made, but no response arrived in time.
502 = upstream returned something invalid. 503 = upstream is down or overloaded. 504 = upstream is alive but not responding in time. Your monitoring dashboard should show these three differently — they each require different remediation: code fix, capacity scaling, or timeout tuning.
Spring Boot · global exception handler — correct 5xx mapping
@RestControllerAdvice
public class GlobalExceptionHandler {
// 500 — unexpected application exception
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleUnexpected(Exception ex) {
log.error("Unhandled exception: {}", ex.getMessage(), ex);
// NEVER expose internal exception details to the client
return ResponseEntity.internalServerError() // 500
.body(new ErrorResponse("INTERNAL_ERROR", "An unexpected error occurred"));
}
// 503 — dependency unavailable (e.g. DB connection pool exhausted)
@ExceptionHandler(ServiceUnavailableException.class)
public ResponseEntity<ErrorResponse> handleUnavailable(ServiceUnavailableException ex) {
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE) // 503
.header("Retry-After", "30")
.body(new ErrorResponse("SERVICE_UNAVAILABLE", "Service temporarily unavailable"));
}
}
"A client should be able to understand what happened from the status code alone. If they need to parse the body to detect failure — your API is not speaking HTTP."
The Lazy Developer Anti-Pattern: Returning 200 OK for Everything
This is the section that will get shared. Because every developer has seen this code, and many have written it. The "200 OK with error body" pattern is so widespread that some teams have convinced themselves it is a legitimate design choice. It is not. It is an API that lies to every system that calls it.
return ResponseEntity.ok(
Map.of("success", false, "message", "User not found"));
If the request failed, stop returning 200 just because JSON was returned successfully. The HTTP transport worked fine. The operation did not. Those are two different things — use two different status codes.
Java · the 200 OK anti-pattern in all its forms
// Form 1 — success field in body
@GetMapping("/users/{id}")
public ResponseEntity<?> getUser(@PathVariable Long id) {
User user = userService.findById(id);
if (user == null) {
return ResponseEntity.ok(Map.of("success", false, "error", "User not found"));
// 200 OK — every monitoring tool, retry mechanism, and
// load balancer thinks this request succeeded. It did not.
}
return ResponseEntity.ok(user);
}
// Form 2 — wrapper with status field
{ "status": "error", "code": 404, "message": "Not found" } // returned with HTTP 200
// The HTTP layer says success. The body says failure.
// Clients must parse the body to know if the request succeeded.
// Every HTTP-aware tool in your infrastructure is now useless.
// Form 3 — RPC-style flat error (common in older Java/XML-RPC style APIs)
{ "result": null, "errorCode": "USR_NOT_FOUND" } // HTTP 200
// Retry libraries will not retry. Alerts will not fire.
// Error dashboards will show 0% error rate. The bug is invisible.
The downstream consequences are not theoretical. A load balancer that sees consistent 200 OK responses will never circuit-break a failing service. An alerting system configured to alert on 5xx rates will show zero alerts while your users hit errors. A search engine crawler receiving 200 OK on a page that displays "Product not found" will continue to index that page and rank it. Every one of these is a real operational problem that 200 OK masking causes.
When every response is 200 OK, debugging a production incident means parsing body content rather than filtering logs by status code. A query that takes 5 seconds in Datadog, Splunk, or CloudWatch: status:5xx. The equivalent with 200-for-everything: reading individual response bodies. I have spent hours on incidents that would have been five-minute queries if the API had returned the correct status codes.
The 10 HTTP Status Code Mistakes I Keep Seeing in Real APIs
The same ten mistakes appear in almost every API codebase I review. Bookmark it. Use it in code review.
| Wrong practice | Wrong code | Correct code | Why it matters |
|---|---|---|---|
| Validation failure on input fields | 200 | 422 | Retry logic disabled; monitoring shows 0% error rate |
| Unauthorized token (missing or expired) | 500 | 401 | Every bad token fires a server error alert |
| Forbidden resource (authenticated, wrong role) | 404 | 403 | Client cannot distinguish not-found from not-allowed |
| Duplicate record on creation | 500 | 409 | Frontend retries creation; error looks like a server crash |
| Async job queued (not yet complete) | 200 | 202 | Client assumes completion; no polling triggered |
| Successful delete, no response body | 200 | 204 | Clients parse a body that does not exist; mobile parse errors |
| Missing record (ID not in DB) | 500 | 404 | Dashboards alert on every missing-ID lookup as a server crash |
| Unsupported Content-Type / media format | 400 | 415 | Client cannot distinguish malformed body from wrong media type |
| Rate limit exceeded | 500 | 429 | No Retry-After; clients retry immediately; thundering herd |
| Downstream dependency unavailable (DB, cache) | 500 generic | 503 | Looks like a code bug; Kubernetes probe cannot distinguish from crash |
Real API Examples — What Mature Systems Actually Return
Theory is one thing. Here is how well-designed production APIs from mature engineering organisations actually use these codes.
| Scenario | Correct Code | Response includes | Common mistake |
|---|---|---|---|
| GET /users/42 — user exists | 200 | User object in body | — |
| POST /users — created | 201 | Created user + Location header |
Returning 200 with no Location header |
| POST /reports/generate — async | 202 | Job ID + status polling URL | Returning 200 with "pending" in body |
| DELETE /users/42 | 204 | No body | Returning 200 with {"deleted": true} |
| GET /users/99 — not found | 404 | Error message, no stack trace | 200 with {"user": null} |
| GET /old-product-slug — permanently removed | 410 | Brief message, no redirect | 404 (misses deindex signal to crawlers) |
| No auth token on protected route | 401 | WWW-Authenticate header |
403 (client shows wrong UI state) |
| ADMIN route, user lacks role | 403 | Access denied message | 401 (triggers re-auth loop) |
| POST /users — email already exists | 409 | Conflict detail, field that conflicts | 400 (obscures the conflict nature) |
| POST /users — invalid email format | 422 | Field-level validation errors | 400 (obscures which validation failed) |
| Rate limit exceeded | 429 | Retry-After header + seconds |
503 or 400 (clients cannot distinguish) |
| Unhandled exception in service | 500 | Generic message, no stack trace | 200 with error object in body |
| Database connection pool exhausted | 503 | Retry-After + brief message |
500 (obscures the temporary nature) |
| Upstream service timeout | 504 | Gateway timeout message | 500 (obscures source of the failure) |
Test what status codes your endpoints actually return in production — including under error conditions. A browser-based REST client (such as REST API Tester) lets you send requests with missing tokens, malformed bodies, and invalid fields to verify your API returns the correct codes before your API consumers discover the discrepancies.
The 10 HTTP Status Code Mistakes I Keep Seeing in Real APIs
This table ranks independently in search. Bookmark it. Add it to your team wiki. These are not edge cases — they are standard misuses that appear in production systems across every company size.
| Scenario | Wrong Code Used | Correct Code | Why It Matters |
|---|---|---|---|
| Validation failure on DTO field | 200 or 500 | 422 | Retry logic fires; client cannot show field errors |
| Missing or expired auth token | 500 | 401 | Error dashboards show false server incidents on every bad login |
| Authenticated, insufficient role | 404 (hiding existence) | 403 | Frontend shows wrong state; auth loop never resolves |
| Duplicate record on create | 500 | 409 | Client retries creation; monitoring floods with server errors |
| Async job queued, not complete | 200 | 202 | Client assumes completion; no polling URL or job ID returned |
| Successful delete, no body | 200 with body | 204 | Inconsistent contract; some clients error on unexpected body |
| Record not found by ID | 500 | 404 | Server error metrics polluted by normal not-found lookups |
| Unsupported content type | 400 | 415 | Client cannot distinguish malformed body from wrong content type |
| Rate limit exceeded | 500 | 429 | No Retry-After signal; clients cannot back off correctly |
| Downstream service unavailable | 500 (generic) | 503 | Infra outage looks like code bug; circuit breakers cannot distinguish |
Real API Response Mistakes I Still See in Production
These are not hypothetical. Every item below is a pattern I have found in a production codebase in the last two years. Some were in systems handling millions of requests per day.
- ✗
200 OKwith{"status": "failed"}in the body — the single most common pattern across every team level - ✗
500 Internal Server Errorreturned when user submits an invalid email/password — this is a client error (401or422), not a server failure. Every invalid login fires a server error alert. - ✗
404 Not Foundwhen a resource with that unique key already exists on creation — this is a409 Conflict. The frontend retries the creation because 404 implies the resource does not exist. - ✗
403 Forbiddenreturned when the Authorization header is missing entirely — this should be401. Clients show "access denied" when they should show the login screen. - ✗
204 No Contentwith a JSON body accidentally returned — usually from a controller method that was refactored. HTTP spec prohibits a body with 204. Some clients silently discard it; others throw parse errors. - ✗
503 Service Unavailablenever used, even when the downstream database is unavailable — the API catches the connection exception and returns500instead, making the outage appear as a code bug rather than an infrastructure issue.
These are not semantic nitpicks. They directly affect client state handling, retry behaviour, dashboard accuracy, and incident diagnosis time. Every one of them added hours to a debugging session that would have been obvious with the correct code.
Senior Developer HTTP Status Code Cheat Sheet
Bookmark this. One table covering every production scenario by response class.
| Scenario | Code | Key header to include | Body? |
|---|---|---|---|
| Read / update completed successfully | 200 | — | Yes |
| Resource created | 201 | Location: /resource/{id} | Yes (new resource) |
| Request queued / async job started | 202 | — | Job ID + status URL |
| Delete / update with no response needed | 204 | — | Never |
| Malformed JSON / wrong Content-Type | 400 | — | Error message |
| Missing or expired auth credentials | 401 | WWW-Authenticate: Bearer | Auth required message |
| Authenticated but insufficient permissions | 403 | — | Access denied message |
| Resource does not exist (may in future) | 404 | — | Brief error |
| Resource permanently deleted / retired | 410 | — | Brief message or empty |
| Unsupported Content-Type or media format | 415 | Accept: application/json | Supported types listed |
| Semantic / business validation failure | 422 | — | Field-level errors |
| State conflict (duplicate, locked, invalid state) | 409 | — | Conflict detail |
| Rate limit exceeded | 429 | Retry-After: 60 | Seconds until retry |
| Unhandled application exception | 500 | — | Generic message only |
| Upstream service returned bad response | 502 | — | Gateway error message |
| Temporarily unavailable / maintenance | 503 | Retry-After: 30 | Brief message |
| Upstream did not respond in time | 504 | — | Timeout message |
Senior Developer HTTP Response Checklist
Use this before shipping any new API endpoint or reviewing an existing one.
- POST that creates a resource returns 201 with a
Locationheader pointing to the new resource 201 Created - DELETE returns 204 with no response body 204 No Content
- Async operations return 202 with a job ID and status polling URL 202 Accepted
- Missing resource returns 404 — permanently deleted resource returns 410 404 vs 410
- Unauthenticated requests return 401 with
WWW-Authenticateheader 401 Unauthorized - Authenticated-but-unauthorised requests return 403, not 401 403 Forbidden
- Malformed request bodies return 400 — validation failures on valid bodies return 422 400 vs 422
- State conflicts (duplicate email, locked resource) return 409 409 Conflict
- Rate limit exceeded returns 429 with
Retry-Afterheader 429 Too Many - Temporary service unavailability returns 503 with
Retry-After, not 500 503 vs 500 - Upstream timeout returns 504, upstream bad response returns 502 502 vs 504
- No endpoint returns 200 OK with error content in the body Anti-pattern
- 500 responses never include stack traces, exception class names, or SQL error text Security
- Error responses include a machine-readable error code alongside the human message Integration
FAQ — HTTP Status Codes for REST APIs
Use 400 when the request itself cannot be parsed — invalid JSON syntax, missing required headers, or unrecognisable structure. Use 422 when the request parsed correctly but failed semantic validation — a well-formed email field containing an invalid email address. Many teams use only 400 for both; 422 is preferred when you want clients to distinguish parsing failures (fix the request format) from content failures (fix the field value). In Spring Boot, HttpMessageNotReadableException maps to 400, MethodArgumentNotValidException maps to 422.
No — this is an anti-pattern that breaks HTTP infrastructure. Returning 200 OK for failures disables retry logic (HTTP clients do not retry 200 responses), makes monitoring unreliable (error rate dashboards show 0%), prevents load balancers from detecting unhealthy nodes, confuses API consumers, and makes log querying useless. Every error condition should return the appropriate 4xx or 5xx code. The body is for human-readable detail; the status code is the machine-readable signal that every system acts on first.
Return 204 No Content when the operation succeeded and there is no meaningful body to return — typically DELETE operations and preference updates where repeating the updated entity would be redundant. Return 200 OK when you return data in the response body. Never include a response body with 204 — the HTTP spec prohibits it, and some clients throw parse errors when they receive one. In Spring Boot: ResponseEntity.noContent().build() returns 204 with no body.
409 Conflict — the request is valid and the user is authorised, but the current state of the database prevents the operation. A duplicate email on user creation, a duplicate order reference, or a unique constraint violation are all 409s. Using 500 for a duplicate key exception is wrong — it treats a normal business constraint as a server failure and floods your error monitoring with expected application behaviour.
401 Unauthorized means no valid authentication credentials were provided — the client needs to authenticate first. Include a WWW-Authenticate header. 403 Forbidden means the client is authenticated but does not have permission — re-authenticating will not help. Swapping them causes wrong UI states (403 shows "access denied" when the user should see a login screen) and integration bugs (401 triggers re-auth loops when the problem is role-based, not credential-based).
Use 503 Service Unavailable when a critical downstream dependency is unavailable or timing out — it signals a temporary infrastructure issue, not a code bug. Include a Retry-After header when the recovery time is estimable. Use 500 for unhandled exceptions in your own application code. The distinction matters for incident diagnosis: 503 tells your on-call engineer to look at infrastructure and dependency health; 500 tells them to look at application logs and code. Returning 500 for a database timeout makes an infrastructure outage look like a code bug.
Status Codes Are Your API's First Line of Communication
Mature APIs are not defined only by authentication, documentation, or speed. They are defined by predictability. A client should be able to understand what happened from the HTTP response alone — without parsing custom flags, undocumented strings, or inconsistent error objects. That predictability begins with returning the correct status code every single time.
A 200 OK on a validation failure is not just wrong — it is a lie the API tells to every system downstream. It tells retry logic that nothing went wrong. It tells monitoring that nothing should alert. It tells the load balancer that the node is healthy. It tells the frontend there is no error state to handle. The real error is invisible until a human traces through logs manually and finds what the infrastructure was never able to surface.
The decision tree in this article is not complex. Six questions, answered in order, before every response. The codes that result from those questions are not arbitrary conventions — they are commitments. The frontend depends on them. The gateway depends on them. The on-call engineer at 2am depends on being able to run status:5xx and get their answer in three seconds, not thirty minutes.
Pick the correct code. Return it consistently. Make your API predictable. That is what separates a reference implementation from a production-grade one. — Priya
Senior Developer HTTP Status Code Cheat Sheet
Bookmark this. Print it. Add it to your team wiki. This is the condensed reference version of everything above — sorted by scenario for fast lookup.
| Scenario | Code | Include in Response |
|---|---|---|
| GET — resource returned | 200 | Resource body |
| POST — resource created | 201 | Created resource + Location header |
| Async job accepted | 202 | Job ID + status URL |
| DELETE / no-body update | 204 | No body (prohibited by spec) |
| Malformed request / bad JSON | 400 | Error description |
| Missing / expired token | 401 | WWW-Authenticate header |
| Authenticated, no permission | 403 | Access denied message |
| Resource not found | 404 | Brief message, no stack trace |
| Resource permanently removed | 410 | Brief message, deindex signal |
| Unsupported content type | 415 | Expected content type |
| Semantic validation failure | 422 | Field-level errors |
| State conflict / duplicate | 409 | Conflicting field detail |
| Rate limit exceeded | 429 | Retry-After header (seconds) |
| Unhandled application exception | 500 | Generic message only — no stack trace |
| Upstream returned invalid response | 502 | Gateway error message |
| Service temporarily unavailable | 503 | Retry-After if known |
| Upstream timed out | 504 | Timeout message |
Before Release, Run These 3 API Response Checks
Fire test requests and verify your status codes match your intent — before your consumers discover the discrepancy.
- 🛠️ Fire sample requests with missing tokens, malformed bodies, and invalid fields in the REST API Tester
- ✅ Verify the status code and payload pair — does a 422 include field-level errors? Does a 201 include a Location header?
- 🔍 Check response headers manually —
WWW-Authenticateon 401,Retry-Afteron 429 and 503