HTTP Status Codes: The Complete Guide for Developers
Consider an API that returns 200 OK for every request — successful writes, validation errors, auth failures, all of them — with an error boolean in the JSON body that the frontend has to parse. It works on a local machine, but it’s broken in ways that cause production problems:
- Intermediate proxy caches store error responses as valid, successful results.
- Monitoring tools report 100% success while authentication is completely down.
- Every downstream consumer has to run
JSON.parse()before knowing whether the request even succeeded.
HTTP status codes exist for a reason. They’re the first thing a client checks, the first thing monitoring tools count, and often the only thing reverse proxies (Nginx) and CDNs (Cloudflare) understand.
This guide explains the nuances between confusing codes (401 vs 403), how to use 409 for optimistic locking, and how to structure responses for SEO and reliability.
1. The Five Categories
Status codes are grouped into five classes by their first digit:
| Range | Category | Meaning |
|---|---|---|
| 1xx | Informational | The connection was received; processing continues. (Rare in modern REST.) |
| 2xx | Success | The request was received, understood, and accepted. |
| 3xx | Redirection | Further action is needed by the client to complete the request. |
| 4xx | Client Error | The request is malformed or can’t be fulfilled. (Client’s fault) |
| 5xx | Server Error | The server failed to fulfill a valid request. (Server’s fault) |
The distinction between 4xx and 5xx is the foundation of reliable monitoring. 4xx means the client made a bad request and the backend is fine. 5xx means the backend crashed and engineers need to be paged. Getting this wrong breaks your SLA reporting.
2. The 2xx Success Range
Most developers use 200 OK for everything. Be more precise.
200 OK
The baseline. Use it for successful GET requests that return data, and for PUT/PATCH updates that return the modified resource.
GET /api/v1/users/123 → 200 OK
{ "id": 123, "name": "Alice Developer" }
201 Created
Use 201 when a POST successfully creates a new resource. Always include a Location header pointing to the new resource’s URL.
POST /api/v1/users → 201 Created
Location: /api/v1/users/456
{ "id": 456, "name": "New User Bob" }
Why it matters: a generic 200 makes it impossible for consumers to tell “I updated an existing record” from “I created a new one” without parsing the body.
204 No Content
Use 204 for successful requests that return no body. It’s the standard for DELETE operations.
DELETE /api/v1/users/123 → 204 No Content (empty body)
Rule: a 204 response must not include a body. If your server sends one with a 204, the browser throws an error. If you need a confirmation string after a deletion, use 200 OK instead.
3. The 3xx Redirection Range: SEO and Caching
301 Moved Permanently
The resource has permanently moved. Google transfers the SEO authority (PageRank) from the old URL to the new one. Use it when: permanently restructuring marketing pages or changing your domain.
GET /old-pricing-page → 301 Moved Permanently
Location: /new-enterprise-pricing
302 Found (temporary redirect)
The resource is temporarily at a different URL, but the client (and Googlebot) should keep using the original for future requests.
Use it for: maintenance pages, geo-locale redirects (routing an IP to /en-us), or A/B tests.
The 301 vs 302 mistake
Using 302 when you meant 301 is one of the most damaging SEO mistakes. A 302 tells search engines, “Don’t update your index, keep sending traffic to the old URL.” If you later delete the old URL, that SEO authority vanishes into a 404. Always use 301 for permanent URL changes.
304 Not Modified
The resource hasn’t changed since the client’s last request, so the browser uses its local cache. This is negotiated automatically between the browser and Nginx using ETag and If-None-Match headers. You rarely set 304 manually, but understanding it is key to debugging CDN caching issues.
4. The 4xx Client Error Range
400 Bad Request
The request is malformed. Use it for invalid JSON, missing required fields, or values that fail schema validation.
POST /api/v1/users
{ "email": "not_an_email", "age": -50 }
→ 400 Bad Request
{
"error": "ZOD_VALIDATION_FAILED",
"details": [
{ "field": "email", "message": "Must be a valid RFC-5322 email string" },
{ "field": "age", "message": "Must be a positive integer" }
]
}
Always include a specific, machine-readable error payload. A bare 400 with no body is useless for debugging.
401 Unauthorized vs 403 Forbidden
These two are constantly confused.
- 401 Unauthorized (authentication failure): the client didn’t provide a token, or the token failed signature validation. Fix: redirect the user to
/login. - 403 Forbidden (authorization failure): the client is authenticated — we know who they are — but lacks permission for this resource. Fix: re-authenticating won’t help. Show a “You lack admin privileges” message.
# Missing JWT token
GET /api/v1/admin/billing → 401 Unauthorized
# Valid token, but user role = 'Guest'
GET /api/v1/admin/billing → 403 Forbidden
Authorization: Bearer valid_guest_jwt_token_here
Debugging JWT claims? Our local JWT Decoder inspects token claims, expiration timestamps, and
rolearrays without transmitting your session tokens over the network.
404 Not Found (the security nuance)
The resource doesn’t exist. But there’s a security nuance: if a resource does exist and the user lacks permission to see it, should you return 403 or 404?
The secure choice: return 404. If you return 403, an attacker can iterate through IDs (/api/users/1, /api/users/2) and map your private data by seeing which return 403 vs 404. Returning 404 universally prevents this enumeration attack.
409 Conflict (optimistic locking)
The request conflicts with the current server state. Use it to enforce unique constraints (duplicate emails) or implement optimistic locking in high-concurrency systems.
# The client updates a document but provides an outdated version
PUT /api/v1/documents/123
{ "content": "New text", "version": 4 }
→ 409 Conflict
{ "error": "DOCUMENT_STALE", "message": "The document is at version 5. Please fetch the latest." }
422 Unprocessable Entity
The request is well-formed (valid JSON) but semantically invalid based on business logic. The syntax is fine; the system rejects the context.
POST /api/v1/orders
{ "quantity": 500, "product_id": "ABC_123" }
→ 422 Unprocessable Entity
{ "error": "INSUFFICIENT_INVENTORY", "message": "Product ABC_123 only has 5 units remaining." }
429 Too Many Requests (rate limiting)
The client exceeded its rate limit. Include a Retry-After header telling the client how many seconds to wait.
GET /api/v1/heavy-export → 429 Too Many Requests
Retry-After: 3600
{ "error": "RATE_LIMIT_EXCEEDED", "message": "Your tier allows 100 req/hr. Try again in 3600 seconds." }
5. The 5xx Server Error Range
500 Internal Server Error
The generic “something went wrong internally.” Security rule: in production, never expose internal error details (database stack traces, IAM errors) in the response body — attackers use them to map your architecture. Log the stack trace internally and return a sanitized message.
// ❌ Leaking internal architecture
{ "error": "Connection refused: PostgreSQL at 10.0.0.4:5432 User=postgres_admin" }
// ✅ Secure
{ "error": "INTERNAL_SERVER_ERROR", "message": "An unexpected failure occurred. Our team has been notified." }
502 Bad Gateway
The server, acting as a reverse proxy (Nginx), got an invalid or empty response from the upstream app server. You’ll see this when your Node app crashes and Nginx can’t route the connection.
503 Service Unavailable
The server is temporarily unable to handle the request — usually scheduled maintenance or a load-shedding circuit breaker under heavy traffic. Include a Retry-After header.
504 Gateway Timeout
The upstream server or database didn’t respond to the Nginx proxy within the timeout (e.g., a slow SQL query taking 45 seconds when Nginx enforces a 30-second limit).
Further Reading
- CORS Errors Explained: Origin Boundaries
- JSON & REST API Best Practices
- API Rate Limiting: Token Bucket Algorithms
- Webhooks Architecture: Idempotency Keys
Stop guessing how your JSON payloads are formatted. Use our local JSON Formatter to beautify and validate payloads, and the local JWT Decoder to debug auth failures without sending tokens over the network.