HTTP Status Codes
Explained

📅 Apr 2025 ⏱ 10 min read 🏷

Most APIs return 200 for everything and dump an error message in the body. That's not an API — that's a guessing game. Here's every status code you actually need, when to use it, and when you're using it wrong.

Why Status Codes Matter

An HTTP status code is the first thing a client reads. Before it parses the body, before it checks headers — it reads the status. A well-chosen code tells the client exactly what happened: did it work, did the client do something wrong, did the server fail, or does the client need to go somewhere else?

When you return 200 OK with { "success": false } in the body, you've broken the protocol. The client's HTTP layer sees success, but the application layer sees failure. Now every consumer of your API has to parse the body just to know if the request worked. Libraries, monitoring tools, load balancers, and retry logic all depend on status codes being correct.

Status codes are grouped into five classes by their first digit. Each class has a clear semantic meaning — and staying within those semantics makes your API predictable to every developer, tool, and piece of infrastructure that touches it.

💡

The five classes: 1xx Informational  ·  2xx Success  ·  3xx Redirection  ·  4xx Client Error  ·  5xx Server Error

This guide focuses on the codes you'll actually use — with Express examples, common mistakes, and the edge cases that trip up even experienced engineers.

The Most Common Mistakes

These patterns appear in production APIs constantly. Each one makes your API harder to consume, debug, and monitor.

200 OK for Everything

Returning { success: false, error: '...' } with a 200 status. The HTTP layer reports success. Load balancers don't alert. Retry logic doesn't trigger. Monitoring dashboards show green. The bug is invisible until a user reports it.

500 for Client Errors

A user sends a malformed request and your unhandled exception handler catches it and returns 500. Now the client thinks the server is broken and keeps retrying. Use proper validation and return 400 or 422 before the error ever reaches your exception handler.

404 for Validation Errors

Returning 404 when a required field is missing or a parameter is invalid. 404 means the URL doesn't exist. Validation errors are 400 or 422 — not 404. Clients expecting 400 won't handle a 404 from your validation logic.

No Error Detail in 4xx Responses

Returning { error: 'Bad request' } with no context. Which field? What value? What's expected? A good 400 response saves 30 minutes of debugging for the API consumer. Include field names, received values, and expected formats.

Using 200 for Empty Results

Returning { data: null } with 200 when a resource isn't found. If GET /users/999 returns 200, the client has no way to distinguish "user exists and is empty" from "user doesn't exist". Return 404.

4xx — Client Errors

The client did something wrong. The request was bad, unauthorized, forbidden, or conflicting. Don't return 5xx for client mistakes — that tells the client to retry, which won't help.

400 Bad Request

The request is malformed — missing fields, wrong types, invalid values. Always include a body explaining what is wrong. A 400 with no body forces the client to guess.

// Validation failed
res.status(400).json({
  error: 'Validation failed',
  fields: {
    email: 'Invalid email format',
    age:   'Must be a positive integer'
  }
})
🔑
401 Unauthorized

No valid authentication provided. The name is misleading — it means unauthenticated, not unauthorized. Include a WWW-Authenticate header indicating the auth scheme. The client should authenticate and retry.

// Missing or invalid token
res.status(401)
  .header('WWW-Authenticate', 'Bearer')
  .json({ error: 'Authentication required' })
🚫
403 Forbidden

Authenticated but not allowed. The server knows who you are, but you don't have permission to do this. Unlike 401, retrying with different credentials won't help — the action is simply not permitted for this identity.

// User authenticated but lacks permission
if (!user.roles.includes('admin')) {
  return res.status(403).json({
    error: 'Insufficient permissions'
  })
}
🔍
404 Not Found

The resource doesn't exist at this URL. Can also be used deliberately to hide the existence of a resource from unauthorized users — returning 404 instead of 403 to avoid leaking whether a resource exists.

const user = await User.findById(req.params.id)
if (!user) {
  return res.status(404).json({ error: 'User not found' })
}
409 Conflict

The request conflicts with the current state of the resource. Use this for duplicate key violations, concurrent edit conflicts, or when an idempotency lock is already held. Much more informative than a generic 400.

// Duplicate email on registration
try {
  await User.create(req.body)
} catch (err) {
  if (err.code === 11000) {
    return res.status(409).json({
      error: 'Email already registered'
    })
  }
}
💀
410 Gone

The resource existed but has been permanently deleted. Unlike 404, this signals the client should remove any references to this URL. Useful for deprecated API endpoints or deleted content.

// Deprecated endpoint, permanently removed
res.status(410).json({
  error: 'This endpoint has been removed',
  alternative: '/api/v2/users'
})
📋
422 Unprocessable Entity

The request is syntactically valid but semantically wrong. The body is well-formed JSON, but the business logic validation failed — e.g. end date before start date, insufficient balance. Use 400 for format errors, 422 for logic errors.

// Semantically invalid — end before start
if (req.body.endDate <= req.body.startDate) {
  return res.status(422).json({
    error: 'endDate must be after startDate'
  })
}
🚦
429 Too Many Requests

Rate limit exceeded. Always include Retry-After and X-RateLimit-* headers so clients know when they can try again and how much quota they have left.

res.status(429)
  .header('Retry-After', '60')
  .header('X-RateLimit-Limit', '100')
  .header('X-RateLimit-Remaining', '0')
  .header('X-RateLimit-Reset', resetTimestamp)
  .json({ error: 'Rate limit exceeded' })

5xx — Server Errors

Something went wrong on the server. The client did nothing wrong — the request was valid. These codes signal that retrying may help (with appropriate backoff).

💥
500 Internal Server Error

An unexpected server-side error. The catch-all for unhandled exceptions. Never leak stack traces or internal details to the client — log them server-side, return a generic message. Always monitor 500s.

app.use((err, req, res, next) => {
  console.error(err) // log full error internally
  res.status(500).json({
    error: 'An unexpected error occurred'
    // never send err.message or err.stack
  })
})
🌐
502 Bad Gateway

Your server is acting as a proxy/gateway and received an invalid response from an upstream service. Common when a microservice is down or returning garbage. Nginx returns this when your Node.js app crashes.

// Upstream service returned bad response
try {
  const upstream = await callPaymentService()
} catch (err) {
  res.status(502).json({ error: 'Payment service unavailable' })
}
🔧
503 Service Unavailable

The server is temporarily unavailable — overloaded or down for maintenance. Include a Retry-After header. Use this during graceful shutdowns, health check failures, or planned downtime.

// During graceful shutdown
process.on('SIGTERM', () => {
  isShuttingDown = true
})

app.use((req, res, next) => {
  if (isShuttingDown) {
    return res.status(503)
      .header('Retry-After', '10')
      .json({ error: 'Server shutting down' })
  }
  next()
})
504 Gateway Timeout

The gateway didn't receive a response from the upstream service in time. Different from 502 — the upstream is reachable but too slow. Common in long-running operations. Consider 202 Accepted + async processing instead.

// Set upstream timeout, return 504 on expiry
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), 5000)

try {
  const data = await fetch(upstreamUrl, {
    signal: controller.signal
  })
} catch (err) {
  if (err.name === 'AbortError') {
    return res.status(504).json({ error: 'Upstream timeout' })
  }
} finally {
  clearTimeout(timeout)
}

5xx vs 4xx retry behaviour: 4xx errors should not be retried — the client needs to fix the request first. 5xx errors can be retried with exponential backoff and jitter, but only if the request is idempotent or protected by an idempotency key.

2xx — Success

The request was received, understood, and accepted. But not all successes are the same — using the right 2xx code tells the client exactly what happened.

200 OK

The default success. Use it for GET and PUT responses when you're returning the resource. Don't use it for creation — that's what 201 is for.

// GET /users/1 — return the user
res.status(200).json(user)

// PUT /users/1 — return updated user
res.status(200).json(updatedUser)
🆕
201 Created

A new resource was created. Always use this for successful POST requests that create something. Include the created resource in the body and ideally a Location header pointing to it.

// POST /users — resource created
res.status(201)
  .header('Location', `/users/${user.id}`)
  .json(user)
202 Accepted

The request was accepted for processing but hasn't been completed yet. Use this for async operations — background jobs, queue processing, email dispatch. The work will happen, just not right now.

// POST /reports/generate — queued async job
await queue.add('generate-report', payload)
res.status(202).json({ message: 'Report queued', jobId })
🗑️
204 No Content

Success, but there's nothing to return. The canonical response for DELETE and for PUT/PATCH when you don't want to return the updated resource. Send no body — a body with 204 is invalid.

// DELETE /users/1
await User.findByIdAndDelete(req.params.id)
res.status(204).send() // no body
⚠️

Common mistake: Returning 200 for everything — including creates and deletes. Use 201 when something is created, 204 when there's nothing to return. Clients and monitoring tools depend on this distinction.

TL;DR

text
HTTP status code cheatsheet:

  200  → success, returning data
  201  → resource created (POST)
  202  → accepted, processing async
  204  → success, no body (DELETE)

  301  → moved permanently (cache it)
  304  → not modified, use your cache
  307  → temp redirect, keep the method

  400  → malformed request, fix your payload
  401  → not authenticated
  403  → authenticated but not allowed
  404  → resource doesn't exist
  409  → conflict with current state
  422  → valid format, invalid logic
  429  → slow down, rate limited

  500  → unexpected server error (monitor this)
  502  → upstream returned garbage
  503  → server unavailable, retry later
  504  → upstream too slow

  Rule: 4xx = client's fault, don't retry
  Rule: 5xx = server's fault, retry with backoff
  Rule: never return 200 with error in body

Status codes are the vocabulary of HTTP. Using them correctly makes your API self-documenting, debuggable, and compatible with every tool in the ecosystem — from load balancers to retry libraries to APM dashboards.

3xx — Redirection

The client needs to take further action to complete the request — usually go to a different URL. The right 3xx code determines whether the method changes and whether browsers cache the redirect.

🔀
301 Moved Permanently

The resource has permanently moved to a new URL. Browsers and crawlers cache this aggressively. The client may change a POST to GET on the redirected request. Use for permanent API version migrations or domain changes.

// Old endpoint permanently moved
res.redirect(301, '/api/v2/users')
↩️
302 Found

Temporary redirect. The resource is temporarily at a different URL but the original URL is still valid. Don't cache. Most clients change POST to GET on redirect — use 307 if you need to preserve the method.

// Temporary maintenance redirect
res.redirect(302, '/maintenance')
💾
304 Not Modified

The client's cached version is still current — don't re-download. Works with ETag and Last-Modified headers. Massive bandwidth saving for GET-heavy APIs and static assets.

const etag = generateETag(data)
if (req.headers['if-none-match'] === etag) {
  return res.status(304).send()
}
res.setHeader('ETag', etag).json(data)
🔄
307 Temporary Redirect

Temporary redirect that preserves the HTTP method. A POST stays a POST after the redirect. Use this instead of 302 when the method must not change — e.g. redirecting a form submission.

res.redirect(307, '/api/v2/payments')

Written for backend engineers who want to build APIs that speak clearly.

HTTP Node.js API design backend REST