Development Utilities
Published on: May 3, 2026
10 min read

HTTP Status Codes: A Senior Developer's Decision Tree for Returning the Right Response

✍️ By Priya Singh (Principal Software Engineer)

Principal Software Engineer

HTTP Status Codes: A Senior Developer's Decision Tree for Returning the Right Response
By Priya SinghSenior Technical Insights
Try the Tool

Ready to put this into practice?

We've built a high-performance REST API Tester specifically for the topics discussed in this article. It's free, secure, and runs entirely in your browser.

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

200 OKused for validation errors
500returned for user mistakes
204returned with body content
404returned for authorization problems

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 failuresClients cannot distinguish success from failure — retries disabled, monitoring silent
500 returned for user mistakesMonitoring fills with false server incidents, obscures real application bugs
401 and 403 used interchangeablyAuth flows become inconsistent — login loops or wrong UI state shown to users
404 used for business rule conflictsFrontend retry logic breaks — 404 implies "not found," not "already exists"

Why HTTP Status Codes Are an API Contract, Not Decoration

⚠️
From a real enterprise audit last year

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.response pattern 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 probeshttpGet probes 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."

WRONG STATUS CODE — WHAT BREAKS DOWNSTREAM YOUR API returns 200 OK {"success":false} Retry logic disabled client sees "success" → no retry Monitoring silent error rate = 0% in dashboards Load balancer confused routes to "healthy" failing node Frontend logic broken no auth redirect on "success" SEO crawler misled keeps indexing error pages Circuit breaker stays open failures masked, no fallback fires
Returning 200 OK for errors silently breaks six downstream systems — none of them can detect or respond to the failure.

The Senior Developer HTTP Status Decision Tree

Before choosing a status code, answer these questions in order:

HTTP STATUS CODE DECISION TREE — SENIOR DEVELOPER FRAMEWORK Did the request reach and parse correctly? malformed JSON / bad headers / invalid method NO 400 Bad Request malformed / unparseable YES Is the user authenticated? valid token / session / credentials present NO 401 Unauthorized must authenticate first YES Does the user have permission? role / scope / ownership check NO 403 Forbidden authenticated, not authorised YES Does the resource exist? look up entity by ID / slug / path NO 404 Not Found may exist in future 410 Gone = permanently deleted YES Is the input valid for the operation? validation / business rule / conflict check NO 422 Unprocessable semantic validation failure 409 Conflict = state conflict YES Did processing complete in this request? sync response vs async / queued job ASYNC 202 Accepted queued, processing later SYNC 200 / 201 / 204 exists / created / no content SERVER / INFRA ERRORS 429 — rate limit exceeded 500 — unhandled exception 502/503/504 — upstream/infra
The HTTP status decision tree — ask these six questions in order before choosing a status code.

"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
🚨
The real-world cost of swapping 401 and 403

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.

🚫 Common Production Smell
// 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.

⚠️
Don't Forget the Header

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
💡
410 is a signal to crawlers, not just clients

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.

💡
Why 422 Unprocessable Entity?

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"
    }
  ]
}
Senior Pro Tip: Using a 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
        ));
}
The Retry-After header is not optional

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.

💡
Operational Visibility

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.

In microservices: remember where the error originated

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.

🚫 Common Production Smell
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.

⚠ Anti-pattern — do not ship this
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.

⚠️
The debugging cost

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 fields200422Retry logic disabled; monitoring shows 0% error rate
Unauthorized token (missing or expired)500401Every bad token fires a server error alert
Forbidden resource (authenticated, wrong role)404403Client cannot distinguish not-found from not-allowed
Duplicate record on creation500409Frontend retries creation; error looks like a server crash
Async job queued (not yet complete)200202Client assumes completion; no polling triggered
Successful delete, no response body200204Clients parse a body that does not exist; mobile parse errors
Missing record (ID not in DB)500404Dashboards alert on every missing-ID lookup as a server crash
Unsupported Content-Type / media format400415Client cannot distinguish malformed body from wrong media type
Rate limit exceeded500429No Retry-After; clients retry immediately; thundering herd
Downstream dependency unavailable (DB, cache)500 generic503Looks 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 field200 or 500422Retry logic fires; client cannot show field errors
Missing or expired auth token500401Error dashboards show false server incidents on every bad login
Authenticated, insufficient role404 (hiding existence)403Frontend shows wrong state; auth loop never resolves
Duplicate record on create500409Client retries creation; monitoring floods with server errors
Async job queued, not complete200202Client assumes completion; no polling URL or job ID returned
Successful delete, no body200 with body204Inconsistent contract; some clients error on unexpected body
Record not found by ID500404Server error metrics polluted by normal not-found lookups
Unsupported content type400415Client cannot distinguish malformed body from wrong content type
Rate limit exceeded500429No Retry-After signal; clients cannot back off correctly
Downstream service unavailable500 (generic)503Infra 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 OK with {"status": "failed"} in the body — the single most common pattern across every team level
  • 500 Internal Server Error returned when user submits an invalid email/password — this is a client error (401 or 422), not a server failure. Every invalid login fires a server error alert.
  • 404 Not Found when a resource with that unique key already exists on creation — this is a 409 Conflict. The frontend retries the creation because 404 implies the resource does not exist.
  • 403 Forbidden returned when the Authorization header is missing entirely — this should be 401. Clients show "access denied" when they should show the login screen.
  • 204 No Content with 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 Unavailable never used, even when the downstream database is unavailable — the API catches the connection exception and returns 500 instead, 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.

ScenarioCodeKey header to includeBody?
Read / update completed successfully200Yes
Resource created201Location: /resource/{id}Yes (new resource)
Request queued / async job started202Job ID + status URL
Delete / update with no response needed204Never
Malformed JSON / wrong Content-Type400Error message
Missing or expired auth credentials401WWW-Authenticate: BearerAuth required message
Authenticated but insufficient permissions403Access denied message
Resource does not exist (may in future)404Brief error
Resource permanently deleted / retired410Brief message or empty
Unsupported Content-Type or media format415Accept: application/jsonSupported types listed
Semantic / business validation failure422Field-level errors
State conflict (duplicate, locked, invalid state)409Conflict detail
Rate limit exceeded429Retry-After: 60Seconds until retry
Unhandled application exception500Generic message only
Upstream service returned bad response502Gateway error message
Temporarily unavailable / maintenance503Retry-After: 30Brief message
Upstream did not respond in time504Timeout 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 Location header 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-Authenticate header 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-After header 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

Should validation errors return 400 or 422?

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.

Is it okay to always return 200 OK with success:false?

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.

When should Spring Boot return 204 instead of 200?

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.

What status code should I use for a duplicate database record?

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.

Difference between 401 and 403 in REST APIs?

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).

Should an internal downstream timeout return 500 or 503?

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.

ScenarioCodeInclude in Response
GET — resource returned200Resource body
POST — resource created201Created resource + Location header
Async job accepted202Job ID + status URL
DELETE / no-body update204No body (prohibited by spec)
Malformed request / bad JSON400Error description
Missing / expired token401WWW-Authenticate header
Authenticated, no permission403Access denied message
Resource not found404Brief message, no stack trace
Resource permanently removed410Brief message, deindex signal
Unsupported content type415Expected content type
Semantic validation failure422Field-level errors
State conflict / duplicate409Conflicting field detail
Rate limit exceeded429Retry-After header (seconds)
Unhandled application exception500Generic message only — no stack trace
Upstream returned invalid response502Gateway error message
Service temporarily unavailable503Retry-After if known
Upstream timed out504Timeout 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 manuallyWWW-Authenticate on 401, Retry-After on 429 and 503
Third-Party Links Disclaimer

This article may contain links to third-party websites, documentation, tools, or services for reference and additional information. These external resources are maintained by their respective owners, and LearnHubly does not control or guarantee their availability, accuracy, security, or content. Please review the terms and privacy policies of third-party websites before using their services.

Priya Singh

Java
Spring Boot
React
APIs

Principal Software Engineer • 15+ Years Experience

Priya Singh is a Principal Software Engineer with 15+ years of experience building scalable applications and developer tools. She specializes in backend architecture, APIs, and performance optimization.