Skip to content
ExponaBLOG
ENGINEERING CRAFT · DAY 029 / 100

HTTP status codes that matter: best practices explained

Ten codes cover almost every API response.

Expona Admin10 min read

The Cost of Getting Status Codes Wrong

Every API response sends a number. That number tells the client whether the request succeeded, failed, or needs to retry. Yet teams across production systems still send 200 OK when they mean 400 Bad Request, or 500 when the real problem is client-side. HTTP status codes that matter: best practices explained starts with this simple truth: ten codes cover almost every API response your team will ever send, and knowing which to use saves debugging hours and prevents silent failures in production.

This article walks through the status codes that actually matter in modern API design. We focus on the ones your team will use repeatedly, not the obscure 308 or 418 codes you'll never encounter. By the end, you'll know exactly which code to return in each scenario, and how to avoid the most common pitfalls.

2xx Success Codes: Telling the Client It Worked

200 OK

200 is the default success response. The request succeeded, and the response body contains the result. Use it when a GET, PUT, or PATCH completes without creating new resources. It's the most common code you'll send.

typescript

// Fetch user profile
GET /api/users/42
Response: 200 OK
{
  "id": 42,
  "name": "Alice",
  "email": "alice@example.com"
}

// Update user settings
PUT /api/users/42
Request: { "timezone": "UTC" }
Response: 200 OK
{
  "id": 42,
  "name": "Alice",
  "timezone": "UTC"
}

Real-world scenario: A payment dashboard refreshes user balance. The server queries the database, finds the current balance, and returns it with 200. The client renders the new number immediately.

201 Created

201 means the request succeeded and created a new resource. Always include a Location header pointing to the newly created resource. Use 201 exclusively for POST requests that create entities.

typescript

// Create a new order
POST /api/orders
Request: { "items": [1, 2, 3], "total": 99.99 }
Response: 201 Created
Location: /api/orders/12345
{
  "id": 12345,
  "items": [1, 2, 3],
  "total": 99.99,
  "status": "pending",
  "createdAt": "2024-01-29T10:15:00Z"
}

In an e-commerce system, when a customer submits an order, the server persists it to the database and returns 201 with the new order ID. The client uses the Location header to redirect the user to the order confirmation page without parsing the response body.

204 No Content

204 means the request succeeded but there's no response body. Use it for DELETE operations or for PUT/PATCH when the server doesn't need to echo back the updated resource. It's efficient because it skips serializing and transmitting unnecessary data.

typescript

// Delete a message
DELETE /api/messages/789
Response: 204 No Content
(no body)

// Subscribe to a topic (state changed, no new data needed)
PATCH /api/subscriptions/555
Request: { "topic": "alerts", "enabled": true }
Response: 204 No Content
(no body)

In a ride-hailing system, when a driver cancels a trip, sending 204 tells the mobile app the cancellation succeeded. The app already knows the trip state; it doesn't need the server to echo it back.

4xx Client Error Codes: The Client Made a Mistake

400 Bad Request

400 means the request is malformed. The server couldn't parse it or it violates basic validation rules. Use this for missing required fields, invalid JSON, or type mismatches.

typescript

// Missing required field
POST /api/users
Request: { "name": "Bob" }  // missing email
Response: 400 Bad Request
{
  "error": "Validation failed",
  "details": [
    { "field": "email", "message": "Email is required" }
  ]
}

// Invalid JSON
POST /api/users
Request: { name: "Bob", email: bob@example.com }
Response: 400 Bad Request
{
  "error": "Invalid JSON syntax"
}

Real-world scenario: A SaaS onboarding form sends user data to the backend. The email field is empty. The server rejects it with 400 and lists which fields failed validation. The frontend re-renders the form highlighting the errors.

401 Unauthorized

401 means the client hasn't provided valid authentication credentials. It's not that they lack permission; they haven't proven who they are. Use this when a JWT is missing, expired, or invalid.

typescript

// Missing authorization header
GET /api/me
Response: 401 Unauthorized
{
  "error": "Missing or invalid authentication token"
}

// Expired token
GET /api/me
Headers: { "Authorization": "Bearer eyJ..." }
Response: 401 Unauthorized
{
  "error": "Token expired. Please refresh."
}

In a trading platform, when a user's session expires and they try to place an order, the server returns 401. The client detects this and redirects to login. The user re-authenticates, obtains a fresh token, and retries.

403 Forbidden

403 means the client is authenticated but lacks permission to access this resource. They've proven who they are; they're just not allowed to do this thing. Use 403 when an authenticated user tries to access another user's data or restricted features.

typescript

// User authenticated, but can't delete another user's account
DELETE /api/users/99
Headers: { "Authorization": "Bearer token_for_user_42" }
Response: 403 Forbidden
{
  "error": "You do not have permission to delete this user"
}

// Free tier user trying to access premium feature
GET /api/analytics/advanced
Response: 403 Forbidden
{
  "error": "This feature requires a premium subscription"
}

In a CRM, a sales rep logs in and tries to view another rep's contacts. The server checks permissions, finds the rep isn't authorized, and returns 403. The client disables the "View" button or shows an error message.

404 Not Found

404 means the resource doesn't exist or the endpoint is wrong. It's unambiguous: the thing you asked for isn't here. Use it when a user ID, order ID, or resource path doesn't resolve to anything.

typescript

// User doesn't exist
GET /api/users/99999
Response: 404 Not Found
{
  "error": "User not found"
}

// Typo in endpoint
GET /api/usres/42  // typo: "usres"
Response: 404 Not Found
{
  "error": "Endpoint not found"
}

In a travel booking system, a user tries to modify a reservation that doesn't exist (maybe they deleted it earlier). The server returns 404, and the client shows "Reservation not found" and prompts the user to search for other bookings.

409 Conflict

409 means the request conflicts with the current state of the resource. Use this for race conditions, duplicate entries, or state violations. It's more specific than 400 because it signals a real state problem, not just malformed input.

typescript

// Attempt to create duplicate email
POST /api/users
Request: { "email": "alice@example.com", "name": "Alice Clone" }
Response: 409 Conflict
{
  "error": "Email already exists"
}

// Stale state: user tries to apply a discount code that expired
PATCH /api/orders/12345
Request: { "discountCode": "EXPIRED2023" }
Response: 409 Conflict
{
  "error": "Discount code is no longer valid"
}

In a ride-hailing system, two drivers claim the same trip simultaneously. One succeeds; the other's claim arrives a millisecond later. The server rejects the second claim with 409 because the trip state changed. The second driver's app notifies them the trip is no longer available.

422 Unprocessable Entity

422 means the request is well-formed but semantically invalid. The syntax is correct, but the business logic rejects it. Use this when validation fails at the domain level, not the syntax level.

typescript

// Invalid business logic
POST /api/transfers
Request: { "amount": -50, "from": "acc1", "to": "acc2" }
Response: 422 Unprocessable Entity
{
  "error": "Amount must be positive"
}

// Insufficient funds
POST /api/transfers
Request: { "amount": 10000, "from": "acc_with_500", "to": "acc2" }
Response: 422 Unprocessable Entity
{
  "error": "Insufficient balance. Available: 500, requested: 10000"
}

Distinction: 400 is "the JSON is broken." 422 is "the JSON is fine, but it violates our business rules." A payment API might return 422 if you try to charge a negative amount, but 400 if you send `amount: "abc"`.

429 Too Many Requests

429 means the client is rate-limited. They've hit a quota or sent too many requests in a given window. Include a Retry-After header to tell them when to try again.

typescript

// Rate limit exceeded
GET /api/search?q=pizza
Response: 429 Too Many Requests
Headers: { "Retry-After": "60" }
{
  "error": "Rate limit exceeded. Try again in 60 seconds."
}

Real-world scenario: A data analyst runs batch queries against an analytics API. After 100 requests in 10 minutes, the server returns 429. The client backs off, waits, and retries. Without 429, the client would keep hammering the server and get mysterious timeouts.

5xx Server Error Codes: Your System Has a Problem

500 Internal Server Error

500 means something broke on the server. A database connection failed, an unhandled exception was thrown, or an external service timed out. It's a catch-all for "we don't know what went wrong, but it's our fault."

typescript

// Unhandled exception in request handler
POST /api/orders
Response: 500 Internal Server Error
{
  "error": "Internal server error",
  "requestId": "abc-123-def"  // include for debugging
}

Always log the error details on the server side, include a request ID in the response, and encourage clients to include that ID when reporting the problem. This turns a vague 500 into a traceable incident.

503 Service Unavailable

503 means the server is temporarily unavailable. Use it during maintenance, database migrations, or when a critical dependency (like a payment processor) is down. Include Retry-After to let clients know when to try again.

typescript

// Scheduled maintenance
GET /api/payments
Response: 503 Service Unavailable
Headers: { "Retry-After": "3600" }
{
  "error": "Service under maintenance. Expected downtime: 1 hour."
}

// Dependency failure
POST /api/transfers
Response: 503 Service Unavailable
{
  "error": "Payment gateway is temporarily unavailable. Please try again later."
}

Clients should treat 503 as temporary and implement exponential backoff. Unlike 500, which might never resolve, 503 signals "try again soon."

Common Mistakes

  • Returning 200 for errors: Never return 200 when validation fails or an error occurs. The client's error handler won't fire, and you'll ship silent failures. Always use 4xx or 5xx.
  • Confusing 401 and 403: Teams often return 403 for missing auth and 401 for permission issues. Do the opposite. 401 is "who are you?" 403 is "I know who you are, but you can't do this."
  • Using 400 for everything: 400 is for malformed requests. Use 422 for business logic violations, 409 for conflicts, 404 for missing resources. Specificity helps clients and debuggers.
  • Missing error details: Returning `{ "error": "Error" }` is useless. Include which field failed, what validation rule was broken, or what the actual conflict was. Clients need actionable feedback.
  • Forgetting response bodies on 201 and 204: 201 should include the created resource and Location header. 204 has no body by definition. Don't mix them up.
  • Not implementing retry logic for 429 and 503: If your API rate-limits or goes down, clients must retry intelligently. Provide Retry-After headers and let clients implement exponential backoff.

Key Takeaways

  • 200 OK for general success, 201 Created for new resources, 204 No Content when there's no body to return.
  • 400 Bad Request for malformed input, 401 Unauthorized for missing auth, 403 Forbidden for permission denial.
  • 404 Not Found for missing resources, 409 Conflict for state violations, 422 Unprocessable Entity for business logic failures.
  • 429 Too Many Requests for rate limits, 500 Internal Server Error for unhandled exceptions, 503 Service Unavailable for temporary outages.
  • Always include descriptive error messages, request IDs, and Retry-After headers where relevant.
  • Match the status code to the actual error; precision prevents silent failures and eases debugging.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles