HTTP Status Codes: The Complete Reference for Developers

A senior developer's guide to every HTTP status code that matters — when to use each one, what they actually mean in practice, and how to stop making the mistakes that turn your API into a guessing game for consumers.

18 min read

Table of Contents

  1. Introduction — More Than Just Numbers
  2. How HTTP Status Codes Work
  3. 1xx Informational Responses
  4. 2xx Success
  5. 3xx Redirection
  6. 4xx Client Errors
  7. 5xx Server Errors
  8. Status Codes in Express.js
  9. Status Codes in REST API Design
  10. Common Mistakes
  11. Complete Reference Table
  12. Conclusion

Introduction — More Than Just Numbers

HTTP status codes are the language your server speaks back to every client that makes a request. They are three-digit integers that carry immense semantic weight, and yet the majority of APIs I have reviewed over the past decade treat them as an afterthought. Developers slap 200 OK on everything, stuff the real status into a JSON body, and call it a day. The result is an API that requires clients to parse the response body just to figure out whether their request succeeded, failed due to bad input, or triggered a server meltdown.

That approach is not just sloppy. It breaks the fundamental contract that HTTP was designed around. Proxies, CDNs, load balancers, monitoring tools, and browser caching mechanisms all rely on status codes to make routing, caching, and alerting decisions. When your API returns 200 with {"error": "Not found"} in the body, your CDN happily caches that "successful" response, your monitoring dashboard shows zero errors, and your on-call engineer sleeps through an outage that is silently serving cached error responses to every user.

I learned this lesson the hard way in 2018. We had an API endpoint that returned 200 for every response with the actual status buried in the payload. Our Cloudflare CDN was configured to cache successful GET responses. When the upstream database went down, the API returned 200 with an error message in the body. Cloudflare cached it. For forty-five minutes, every user received a cached error response that looked like a success to every piece of infrastructure between the server and the browser. The fix was straightforward: use the correct HTTP status codes. But the forty-five minutes of downtime and the post-mortem that followed were not.

This guide is the reference I wish I had when I started building APIs. It covers every status code that matters in modern web development, explains when to use each one with concrete examples, and highlights the mistakes that even experienced developers make. Whether you are designing a new REST API, debugging a mysterious 502 from your reverse proxy, or trying to understand why your browser is not caching your static assets, you will find the answer here.

How HTTP Status Codes Work

Every HTTP transaction follows the same fundamental pattern: the client sends a request, and the server sends a response. The response always begins with a status line that contains three elements: the HTTP version, the status code, and a reason phrase. Understanding this structure is essential before diving into the individual codes.

The Request-Response Model

When your browser or API client sends a request, it looks something like this:

GET /api/users/42 HTTP/1.1
Host: api.example.com
Authorization: Bearer eyJhbGciOiJIUzI1NiIs...
Accept: application/json

The server processes the request and responds with a status line followed by headers and an optional body:

HTTP/1.1 200 OK
Content-Type: application/json
Cache-Control: private, max-age=60

{"id": 42, "name": "Jane Doe", "email": "jane@example.com"}

Status Line Anatomy

The status line HTTP/1.1 200 OK breaks down as follows:

The Five Classes

The first digit of the status code defines its class. This is the single most important thing to understand about HTTP status codes because it determines the general category of the response without needing to know the specific code:

Class Range Meaning Who Is Responsible
1xx 100–199 Informational — request received, processing continues Server (interim response)
2xx 200–299 Success — request was received, understood, and accepted Neither (everything worked)
3xx 300–399 Redirection — further action needed to complete the request Server (guiding the client)
4xx 400–499 Client Error — the request contains bad syntax or cannot be fulfilled Client (fix the request)
5xx 500–599 Server Error — the server failed to fulfill a valid request Server (something is broken)
Key Principle: If a client receives a status code it does not recognize, it should treat it as the x00 code of that class. For example, an unrecognized 432 should be treated as 400 Bad Request. This is defined in RFC 9110 and ensures forward compatibility as new status codes are registered.

1xx Informational Responses

The 1xx class of status codes indicates that the server has received the request and the client should continue waiting. These are interim responses — the server will eventually send a final response with a code from one of the other four classes. Most developers never interact with 1xx codes directly because HTTP libraries handle them transparently, but understanding them is important for debugging network-level issues and optimizing performance.

Code Name What It Means When You See It
100 Continue The server has received the request headers and the client should proceed to send the request body Large file uploads where the client sends Expect: 100-continue header first
101 Switching Protocols The server is switching to the protocol requested by the client via the Upgrade header WebSocket handshake — upgrading from HTTP to the WebSocket protocol
103 Early Hints The server sends preliminary headers before the final response, allowing the browser to start preloading resources Performance optimization — the server hints at CSS/JS files to preload while it is still preparing the HTML response

100 Continue in Practice

The 100 Continue mechanism exists to prevent wasting bandwidth. When a client needs to upload a large file, it can send the request headers first with an Expect: 100-continue header. The server checks the headers (authentication, content type, file size limits) and responds with 100 Continue if everything looks good. Only then does the client send the potentially massive request body. If the server rejects the request (for example, the file is too large), the client avoids sending gigabytes of data that would just be discarded.

# Client sends headers first
POST /api/upload HTTP/1.1
Host: api.example.com
Content-Length: 524288000
Expect: 100-continue

# Server responds with 100 Continue
HTTP/1.1 100 Continue

# Client now sends the 500MB file body
[...file data...]

# Server responds with final status
HTTP/1.1 201 Created

103 Early Hints for Performance

The 103 Early Hints status code is a relatively recent addition (RFC 8297) that is gaining traction for front-end performance optimization. When a server needs time to generate the HTML response (database queries, template rendering), it can immediately send a 103 response with Link headers pointing to critical resources. The browser starts fetching those resources in parallel while the server finishes preparing the actual response.

# Server sends early hints immediately
HTTP/1.1 103 Early Hints
Link: </styles/main.css>; rel=preload; as=style
Link: </scripts/app.js>; rel=preload; as=script

# Server sends the actual response 200ms later
HTTP/1.1 200 OK
Content-Type: text/html

<!DOCTYPE html>...
Performance Tip: If you are using a Node.js server behind a CDN like Cloudflare, Early Hints can shave 100-300ms off your page load times. Cloudflare supports 103 Early Hints natively and can cache and serve them even before your origin server responds.

2xx Success

The 2xx class indicates that the client's request was successfully received, understood, and accepted. This is the class most developers are familiar with, but there is more nuance here than just "it worked." Choosing the right 2xx code communicates precisely what happened, which is essential for API consumers and intermediary infrastructure.

Code Name What It Means When to Use It
200 OK The request succeeded. The response body contains the result. GET requests returning data. PUT/PATCH requests returning the updated resource. Any successful request that has a response body.
201 Created The request succeeded and a new resource was created as a result. POST requests that create a new entity. The response should include a Location header pointing to the new resource's URL.
202 Accepted The request has been accepted for processing, but the processing has not been completed. Asynchronous operations. The server queued the work (sending an email, processing a video) but has not finished it yet.
204 No Content The request succeeded but there is no content to return. DELETE requests after successfully removing a resource. PUT/PATCH requests where the client does not need the updated resource echoed back.
206 Partial Content The server is delivering only part of the resource due to a range header sent by the client. Video/audio streaming, resumable file downloads, large file transfers where the client requests specific byte ranges.

200 OK vs 201 Created vs 204 No Content

The distinction between these three codes is something I see junior developers gloss over, but it matters for API clarity. Use 200 when you are returning data in response to a request. Use 201 when a new resource was created — and always include a Location header so the client knows where to find it. Use 204 when the operation succeeded but there is genuinely nothing to send back.

// 200 OK — returning existing data
app.get('/api/users/:id', async (req, res) => {
  const user = await User.findById(req.params.id);
  res.status(200).json(user);
});

// 201 Created — new resource was created
app.post('/api/users', async (req, res) => {
  const user = await User.create(req.body);
  res.status(201)
    .location(`/api/users/${user.id}`)
    .json(user);
});

// 204 No Content — successful deletion, nothing to return
app.delete('/api/users/:id', async (req, res) => {
  await User.destroy(req.params.id);
  res.status(204).end();
});

206 Partial Content for Streaming

The 206 Partial Content response is what makes video streaming and resumable downloads possible. When a client sends a Range header, the server responds with just that portion of the resource along with a Content-Range header indicating which bytes are included. This is how your browser can seek to the middle of a YouTube video without downloading the entire file first, and how interrupted downloads can resume from where they left off.

# Client requests bytes 1000-1999
GET /video.mp4 HTTP/1.1
Range: bytes=1000-1999

# Server returns just those bytes
HTTP/1.1 206 Partial Content
Content-Range: bytes 1000-1999/1000000
Content-Length: 1000
Content-Type: video/mp4

[...1000 bytes of video data...]
Common Pitfall: Do not return 200 OK when you should return 201 Created. API consumers and automated tools rely on the 201 status to know that something new was created. Swagger/OpenAPI documentation generators use the status code to determine the response schema. Returning 200 for a creation operation is technically valid but semantically wrong and makes your API harder to use.

3xx Redirection

The 3xx class indicates that the client needs to take additional action to complete the request, typically following a redirect to a different URL. This is where things get tricky because there are multiple redirect codes that behave differently, and choosing the wrong one has real consequences for SEO, caching, and HTTP method preservation.

Code Name Permanent? Preserves Method? Use Case
301 Moved Permanently Yes No (may change to GET) URL has permanently changed. Old URL should never be used again. Search engines transfer link equity.
302 Found No No (may change to GET) Temporary redirect. Original URL should still be used in the future. Historically ambiguous behavior.
304 Not Modified N/A N/A Resource has not changed since the client last fetched it. Client should use its cached copy.
307 Temporary Redirect No Yes (guaranteed) Temporary redirect that guarantees the HTTP method and body are preserved. POST stays POST.
308 Permanent Redirect Yes Yes (guaranteed) Permanent redirect that guarantees the HTTP method and body are preserved. The modern replacement for 301.

301 vs 302 vs 307 vs 308: The Redirect Matrix

This is one of the most confusing areas of HTTP because the original specification was ambiguous. When HTTP/1.0 defined 301 and 302, it said that the redirected request should use the same method. In practice, browsers changed POST requests to GET requests when following 301 and 302 redirects. This behavior was technically a violation of the spec but became so widespread that it became the de facto standard.

HTTP/1.1 introduced 307 and 308 to resolve this ambiguity. These codes explicitly guarantee that the HTTP method and request body are preserved during the redirect. If a client sends a POST to a URL that responds with 307, the client must send the same POST to the new URL. With 301 or 302, the client is likely to change the POST to a GET, which will break your form submission or API call.

Rule of Thumb: For permanent redirects of GET requests (like changing your URL structure), use 301. For permanent redirects where you need to preserve the method (like permanently moving an API endpoint), use 308. For temporary redirects, use 307. Avoid 302 unless you specifically want the legacy browser behavior of changing POST to GET.

SEO Implications of Redirects

If you care about search engine rankings, redirect codes matter enormously. A 301 Moved Permanently tells search engines to transfer the accumulated link equity (PageRank, domain authority) from the old URL to the new one. A 302 Found tells search engines that the move is temporary, so they keep the old URL in their index and do not transfer link equity. Using 302 when you should use 301 means you are throwing away all the SEO value that the old URL accumulated over time.

// Permanent redirect — SEO equity transfers to new URL
app.get('/old-blog/:slug', (req, res) => {
  res.redirect(301, `/blog/${req.params.slug}`);
});

// Temporary redirect — original URL stays in search index
app.get('/maintenance', (req, res) => {
  res.redirect(307, '/maintenance-page');
});

304 Not Modified and Caching

The 304 Not Modified response is fundamental to HTTP caching. When a client makes a conditional request (using If-None-Match with an ETag or If-Modified-Since with a date), the server can respond with 304 to tell the client that the resource has not changed and the cached version is still valid. This saves bandwidth because no response body is sent.

# Client sends conditional request with cached ETag
GET /api/users/42 HTTP/1.1
If-None-Match: "a1b2c3d4"

# Server checks — nothing has changed
HTTP/1.1 304 Not Modified
ETag: "a1b2c3d4"

# No response body — client uses its cached copy

The 304 mechanism is how browsers avoid re-downloading static assets on every page load, and it is how well-designed APIs reduce bandwidth and server load. If your API serves data that does not change on every request, implementing ETag-based caching with 304 responses can dramatically reduce your payload sizes and improve response times.

4xx Client Errors

The 4xx class indicates that the client made an error in the request. This is the largest and most nuanced class of status codes, and it is where most API design decisions happen. Choosing the right 4xx code tells the client exactly what went wrong and what they need to fix, which is infinitely more useful than a generic "something failed" message.

Code Name What It Means Express.js Example
400 Bad Request The server cannot process the request due to malformed syntax, invalid parameters, or other client-side errors. res.status(400).json({ error: 'Invalid email format' })
401 Unauthorized Authentication is required and has either not been provided or has failed. The client must authenticate. res.status(401).json({ error: 'Authentication required' })
403 Forbidden The server understood the request and the client is authenticated, but the client does not have permission to access the resource. res.status(403).json({ error: 'Insufficient permissions' })
404 Not Found The requested resource does not exist on the server. res.status(404).json({ error: 'User not found' })
405 Method Not Allowed The HTTP method used is not supported for this endpoint. Must include an Allow header listing valid methods. res.status(405).set('Allow', 'GET, POST').json({ error: 'Method not allowed' })
409 Conflict The request conflicts with the current state of the server, such as a duplicate resource or a concurrency conflict. res.status(409).json({ error: 'Email already registered' })
413 Content Too Large The request body exceeds the server's size limit. res.status(413).json({ error: 'File exceeds 10MB limit' })
415 Unsupported Media Type The server does not support the content type of the request body. res.status(415).json({ error: 'Content-Type must be application/json' })
422 Unprocessable Content The request body is syntactically valid JSON but semantically invalid (fails business logic validation). res.status(422).json({ error: 'Age must be between 0 and 150' })
429 Too Many Requests The client has sent too many requests in a given time window. Must include a Retry-After header. res.status(429).set('Retry-After', '60').json({ error: 'Rate limit exceeded' })

401 Unauthorized vs 403 Forbidden

This is the most commonly confused pair of status codes, and the confusion is understandable because the name "Unauthorized" is misleading. Here is the definitive distinction:

// 401 — no token provided or token is invalid
function authenticate(req, res, next) {
  const token = req.headers.authorization?.split(' ')[1];
  if (!token) {
    return res.status(401).json({
      error: 'authentication_required',
      message: 'Please provide a valid Bearer token',
    });
  }
  try {
    req.user = jwt.verify(token, secret);
    next();
  } catch (err) {
    return res.status(401).json({
      error: 'invalid_token',
      message: 'Token is expired or invalid',
    });
  }
}

// 403 — authenticated but lacks permission
function authorize(...roles) {
  return (req, res, next) => {
    if (!roles.includes(req.user.role)) {
      return res.status(403).json({
        error: 'forbidden',
        message: `This action requires one of: ${roles.join(', ')}`,
      });
    }
    next();
  };
}
Security Note: Sometimes you should return 404 instead of 403 for security reasons. If an unauthorized user should not even know that a resource exists, returning 403 reveals its existence. For example, if user A tries to access user B's private document, returning 403 tells user A that the document exists. Returning 404 reveals nothing. GitHub does this — accessing a private repository you do not have access to returns 404, not 403.

400 Bad Request vs 422 Unprocessable Content

Another frequently debated distinction. The rule I follow is: 400 means the request itself is malformed (invalid JSON, missing required headers, wrong content type encoding), while 422 means the request is syntactically correct but fails business logic validation (the email field contains a valid string but it is not a valid email address, the age is a valid number but it is negative). In practice, many APIs use 400 for everything, which is technically acceptable but less precise.

429 Too Many Requests

Rate limiting is essential for any public API, and 429 is the standardized way to communicate it. When you return 429, you should always include a Retry-After header telling the client when they can try again. Good API clients will respect this header and back off automatically. Without it, clients will typically retry immediately, making the overload worse.

const rateLimit = require('express-rate-limit');

const apiLimiter = rateLimit({
  windowMs: 15 * 60 * 1000, // 15 minutes
  max: 100,                  // 100 requests per window
  standardHeaders: true,     // Return rate limit info in RateLimit-* headers
  legacyHeaders: false,
  handler: (req, res) => {
    res.status(429).json({
      error: 'rate_limit_exceeded',
      message: 'Too many requests. Please try again later.',
      retryAfter: Math.ceil(req.rateLimit.resetTime / 1000),
    });
  },
});

app.use('/api/', apiLimiter);

5xx Server Errors

The 5xx class indicates that the server failed to fulfill a request that appears to be valid. These are the codes that trigger pager alerts at 3 AM. Unlike 4xx errors where the client needs to fix something, 5xx errors mean something is wrong on your end. Understanding the specific codes helps you diagnose problems faster and communicate more precisely with your infrastructure team.

Code Name What It Means Real-World Causes
500 Internal Server Error The server encountered an unexpected condition that prevented it from fulfilling the request. Unhandled exception in application code, null pointer dereference, failed database query with no error handling, out of memory, misconfigured environment variables.
502 Bad Gateway The server, acting as a gateway or proxy, received an invalid response from the upstream server. Upstream server crashed or is not running, upstream returned malformed response, network partition between proxy and upstream, DNS resolution failure for upstream host.
503 Service Unavailable The server is temporarily unable to handle the request, usually due to maintenance or overload. Server is deploying a new version, database connection pool exhausted, server is overwhelmed by traffic, planned maintenance window, circuit breaker is open.
504 Gateway Timeout The server, acting as a gateway or proxy, did not receive a timely response from the upstream server. Upstream server is too slow (long-running query), network latency between proxy and upstream, upstream server is overloaded and not responding, connection timeout exceeded.

502 Bad Gateway vs 504 Gateway Timeout

Both 502 and 504 involve a proxy or gateway that is having trouble communicating with an upstream server. The difference is important for debugging: 502 means the upstream responded but the response was invalid (the upstream is broken), while 504 means the upstream did not respond at all within the timeout period (the upstream is slow or unreachable).

In a typical production architecture with Nginx or a load balancer in front of your Node.js application, you will see 502 when your Node.js process has crashed (Nginx tries to connect but gets a connection refused or an invalid response), and you will see 504 when your Node.js process is alive but stuck (a slow database query, a deadlock, or an infinite loop prevents it from responding within Nginx's proxy timeout).

# Nginx config — understanding where 502 and 504 come from
upstream backend {
  server 127.0.0.1:3000;
}

server {
  location /api/ {
    proxy_pass http://backend;
    proxy_connect_timeout 5s;   # 502 if cannot connect within 5s
    proxy_read_timeout 30s;     # 504 if no response within 30s
    proxy_send_timeout 10s;     # 504 if cannot send request within 10s
  }
}

503 Service Unavailable and Retry-After

When returning 503, you should include a Retry-After header telling clients when the service is expected to be available again. This is especially important during planned maintenance. Well-behaved clients and CDNs will respect this header and retry after the specified delay instead of bombarding your server with requests.

// Graceful shutdown — return 503 while draining connections
let isShuttingDown = false;

process.on('SIGTERM', () => {
  isShuttingDown = true;
  // Give existing requests 30 seconds to complete
  setTimeout(() => process.exit(0), 30000);
});

app.use((req, res, next) => {
  if (isShuttingDown) {
    return res.status(503)
      .set('Retry-After', '30')
      .json({ error: 'Server is shutting down. Please retry shortly.' });
  }
  next();
});
Monitoring Insight: Track 5xx error rates as a primary health metric. A sudden spike in 500 errors usually means a code deployment broke something. A spike in 502 errors means your application processes are crashing. A spike in 504 errors means your application is getting slower, often due to database contention or resource exhaustion. Each pattern points to a different root cause and requires a different investigation path.

Status Codes in Express.js

Express.js is the most widely used Node.js framework, and establishing consistent patterns for status code usage across your application is crucial for maintainability. I have seen too many Express apps where status codes are scattered randomly across route handlers with no consistency. Here is the middleware-based pattern I use for every project.

Centralized Error Handling Middleware

The key insight is that error handling should be centralized, not duplicated in every route handler. Define custom error classes that carry the appropriate status code, throw them anywhere in your application, and let a single error-handling middleware translate them into HTTP responses.

// errors/AppError.js
class AppError extends Error {
  constructor(statusCode, code, message) {
    super(message);
    this.statusCode = statusCode;
    this.code = code;
    this.isOperational = true;
  }
}

class NotFoundError extends AppError {
  constructor(resource = 'Resource') {
    super(404, 'NOT_FOUND', `${resource} not found`);
  }
}

class ValidationError extends AppError {
  constructor(message, details = []) {
    super(422, 'VALIDATION_ERROR', message);
    this.details = details;
  }
}

class ConflictError extends AppError {
  constructor(message) {
    super(409, 'CONFLICT', message);
  }
}

class UnauthorizedError extends AppError {
  constructor(message = 'Authentication required') {
    super(401, 'UNAUTHORIZED', message);
  }
}

class ForbiddenError extends AppError {
  constructor(message = 'Insufficient permissions') {
    super(403, 'FORBIDDEN', message);
  }
}

module.exports = {
  AppError, NotFoundError, ValidationError,
  ConflictError, UnauthorizedError, ForbiddenError,
};

Error Handling Middleware

// middleware/errorHandler.js
function errorHandler(err, req, res, next) {
  // Log the full error for debugging
  console.error(`[${new Date().toISOString()}] ${req.method} ${req.url}`, {
    error: err.message,
    stack: err.stack,
    code: err.code,
  });

  // Operational errors — safe to expose to client
  if (err.isOperational) {
    return res.status(err.statusCode).json({
      error: err.code,
      message: err.message,
      ...(err.details && { details: err.details }),
    });
  }

  // Programming errors — do not leak details to client
  res.status(500).json({
    error: 'INTERNAL_ERROR',
    message: 'An unexpected error occurred',
  });
}

module.exports = errorHandler;

Clean Route Handlers

// routes/users.js
const { NotFoundError, ConflictError, ValidationError } = require('../errors/AppError');

router.post('/users', async (req, res) => {
  const { email, name, age } = req.body;

  // Validation — throws 422
  if (!email || !email.includes('@')) {
    throw new ValidationError('Invalid email address', [
      { field: 'email', message: 'Must be a valid email address' },
    ]);
  }

  // Conflict check — throws 409
  const existing = await User.findByEmail(email);
  if (existing) {
    throw new ConflictError('A user with this email already exists');
  }

  // Success — 201 Created
  const user = await User.create({ email, name, age });
  res.status(201)
    .location(`/api/users/${user.id}`)
    .json(user);
});

router.get('/users/:id', async (req, res) => {
  const user = await User.findById(req.params.id);
  if (!user) {
    throw new NotFoundError('User');  // throws 404
  }
  res.status(200).json(user);
});
Express Tip: Express 5 natively handles promise rejections in async route handlers, so throw works as expected. In Express 4, you need to wrap your async handlers with a helper like express-async-errors or manually call next(err). Without this, unhandled promise rejections will silently crash your route instead of being caught by the error middleware.

Status Codes in REST API Design

Designing a REST API requires making deliberate decisions about which status code to return for each operation. The wrong choice does not just confuse developers reading your documentation — it can break client logic, misconfigure caches, and trigger incorrect monitoring alerts. Here is the decision framework I use for every CRUD operation and common edge case.

Which Code for Which Operation

Follow this decision flowchart when determining the appropriate status code for a response:

Did the request succeed?
├── YES
│   ├── Was a new resource created?
│   │   ├── YES → 201 Created (include Location header)
│   │   └── NO
│   │       ├── Is there a response body?
│   │       │   ├── YES → 200 OK
│   │       │   └── NO → 204 No Content
│   │       └── Is processing still ongoing?
│   │           └── YES → 202 Accepted
│   └── Is the cached version still valid?
│       └── YES → 304 Not Modified
└── NO
    ├── Is it the client's fault?
    │   ├── Not authenticated? → 401 Unauthorized
    │   ├── Authenticated but forbidden? → 403 Forbidden
    │   ├── Resource doesn't exist? → 404 Not Found
    │   ├── Wrong HTTP method? → 405 Method Not Allowed
    │   ├── Duplicate/conflict? → 409 Conflict
    │   ├── Malformed request? → 400 Bad Request
    │   ├── Valid syntax but invalid data? → 422 Unprocessable Content
    │   ├── Payload too large? → 413 Content Too Large
    │   └── Too many requests? → 429 Too Many Requests
    └── Is it the server's fault?
        ├── Unhandled exception? → 500 Internal Server Error
        ├── Upstream returned garbage? → 502 Bad Gateway
        ├── Server overloaded/maintenance? → 503 Service Unavailable
        └── Upstream too slow? → 504 Gateway Timeout

REST Operations Mapped to Status Codes

Operation HTTP Method Success Code Common Error Codes
List resources GET /users 200 (always return array, even if empty) 401, 403, 500
Get single resource GET /users/:id 200 401, 403, 404, 500
Create resource POST /users 201 (with Location header) 400, 401, 403, 409, 422, 500
Full update PUT /users/:id 200 or 204 400, 401, 403, 404, 409, 422, 500
Partial update PATCH /users/:id 200 400, 401, 403, 404, 409, 422, 500
Delete resource DELETE /users/:id 204 (no body) or 200 (with deleted resource) 401, 403, 404, 500
Async operation POST /jobs 202 (with status polling URL) 400, 401, 422, 500
Design Principle: Your API should be predictable. Consumers should be able to guess the status code before reading your documentation. If a POST creates something, return 201. If a GET finds nothing, return 404. If a DELETE succeeds, return 204. Consistency across endpoints builds trust and reduces integration bugs.

Idempotency and Status Codes

Idempotency affects which status code you should return when a client repeats a request. PUT and DELETE are idempotent by definition — calling them multiple times should produce the same result. This means if a client sends a DELETE for a resource that was already deleted, you have two reasonable options: return 204 (treating the already-deleted state as success, since the desired outcome is achieved) or return 404 (the resource does not exist). I prefer 204 because it simplifies client retry logic — the client does not need to distinguish between "I deleted it" and "it was already gone."

Common Mistakes

After years of reviewing APIs and debugging production incidents, these are the status code mistakes I encounter most frequently. Each one seems minor in isolation but creates real problems at scale.

Mistake 1: Using 200 for Everything

This is by far the most common mistake. The API returns 200 OK for every response and stuffs the real status into the JSON body:

// DON'T do this
app.get('/api/users/:id', async (req, res) => {
  const user = await User.findById(req.params.id);
  if (!user) {
    return res.status(200).json({
      success: false,
      error: 'User not found',
    });
  }
  res.status(200).json({ success: true, data: user });
});

// DO this instead
app.get('/api/users/:id', async (req, res) => {
  const user = await User.findById(req.params.id);
  if (!user) {
    return res.status(404).json({ error: 'User not found' });
  }
  res.status(200).json(user);
});

The "200 for everything" pattern breaks monitoring (your error rate shows zero), breaks caching (CDNs cache error responses as successes), breaks client-side error handling (every response needs body parsing to determine success), and makes your API a nightmare to debug. HTTP status codes exist specifically to communicate the outcome at the protocol level. Use them.

Mistake 2: Confusing 401 and 403

I have seen APIs that return 403 when no authentication token is provided, and 401 when the user does not have permission. This is backwards. Remember: 401 means "who are you?" (authenticate), 403 means "you cannot do that" (authorize). If you mix them up, API clients cannot implement correct retry logic. A 401 should trigger a token refresh or re-login flow. A 403 should display a "you do not have permission" message. Swapping them breaks the client's ability to respond appropriately.

Mistake 3: Ignoring 429 Rate Limiting

Many APIs implement rate limiting but return 400 Bad Request or 403 Forbidden when the limit is exceeded. This is wrong for two reasons. First, 429 is the correct code for rate limiting and any well-built HTTP client library recognizes it. Second, without the Retry-After header that should accompany a 429, clients have no idea when to retry. The result is aggressive retry loops that make the overload worse.

// DON'T return 403 for rate limiting
res.status(403).json({ error: 'Too many requests' });

// DO return 429 with Retry-After
res.status(429)
  .set('Retry-After', '60')
  .set('X-RateLimit-Limit', '100')
  .set('X-RateLimit-Remaining', '0')
  .set('X-RateLimit-Reset', String(Math.ceil(Date.now() / 1000) + 60))
  .json({
    error: 'rate_limit_exceeded',
    message: 'Rate limit exceeded. Retry after 60 seconds.',
  });

Mistake 4: Returning 500 for Client Errors

When validation fails or a required field is missing, returning 500 Internal Server Error tells the client that the server is broken, when in reality the client just sent a bad request. This inflates your 5xx error metrics, triggers false alarms in monitoring, and confuses developers who are trying to figure out what they did wrong. Always validate input and return the appropriate 4xx code before any server-side processing begins.

Mistake 5: Not Including Required Headers

Several status codes have required or strongly recommended companion headers. Returning 405 Method Not Allowed without an Allow header, 429 Too Many Requests without Retry-After, or 201 Created without a Location header gives the client incomplete information. Check the specification for each status code you use and include all associated headers.

Debugging Tip: If your monitoring shows a sudden spike in 500 errors, the first thing to check is whether the errors are actually client errors being misclassified. I have been paged multiple times for "server errors" that turned out to be a new API consumer sending malformed requests that hit an unhandled code path, causing an exception instead of a proper 400 response. Defensive input validation prevents this entire category of false alarms.

Complete Reference Table

This table covers every HTTP status code you are likely to encounter in modern web development. Keep it bookmarked for quick lookups during development and code reviews.

Code Name Meaning When to Use
1xx — Informational
100 Continue Server received headers, client should send body Large uploads with Expect: 100-continue
101 Switching Protocols Server is changing protocols as requested WebSocket upgrade handshake
103 Early Hints Server sends preliminary headers for preloading Preloading CSS/JS while server generates HTML
2xx — Success
200 OK Request succeeded, response body contains result GET returning data, PUT/PATCH returning updated resource
201 Created New resource was created POST that creates an entity (include Location header)
202 Accepted Request accepted but not yet processed Async operations (video encoding, email sending)
204 No Content Success with no response body DELETE operations, PUT where client does not need echo
206 Partial Content Returning partial resource per Range header Video streaming, resumable downloads
3xx — Redirection
301 Moved Permanently Resource permanently moved (may change method to GET) URL restructuring, domain migration (SEO equity transfers)
302 Found Temporary redirect (may change method to GET) Legacy temporary redirects (prefer 307 for new code)
304 Not Modified Cached version is still valid Conditional requests with ETag/If-Modified-Since
307 Temporary Redirect Temporary redirect, method is preserved Temporary API endpoint moves, maintenance redirects
308 Permanent Redirect Permanent redirect, method is preserved Permanent API endpoint moves where POST must stay POST
4xx — Client Errors
400 Bad Request Request is malformed or invalid Invalid JSON, missing required parameters, bad syntax
401 Unauthorized Authentication required or failed No token provided, expired token, invalid credentials
403 Forbidden Authenticated but not authorized User lacks required role or permission
404 Not Found Resource does not exist Invalid ID, nonexistent endpoint, deleted resource
405 Method Not Allowed HTTP method not supported for this endpoint PUT on a read-only resource (include Allow header)
409 Conflict Request conflicts with current state Duplicate email, optimistic locking conflict, version mismatch
413 Content Too Large Request body exceeds size limit File upload too large, request payload over limit
415 Unsupported Media Type Server does not accept the request content type Sending XML to a JSON-only endpoint
422 Unprocessable Content Valid syntax but failed semantic validation Invalid email format, age out of range, business rule violation
429 Too Many Requests Rate limit exceeded Client exceeded API rate limit (include Retry-After)
5xx — Server Errors
500 Internal Server Error Unhandled server-side failure Uncaught exceptions, unexpected errors, misconfigurations
502 Bad Gateway Upstream server returned invalid response Upstream process crashed, malformed upstream response
503 Service Unavailable Server temporarily unable to handle requests Deployment in progress, overloaded, maintenance
504 Gateway Timeout Upstream server did not respond in time Slow database query, upstream overloaded, network timeout

Conclusion

HTTP status codes are not bureaucratic overhead. They are the primary communication channel between your server and every piece of infrastructure and software that interacts with it. When you use them correctly, your CDN caches the right responses, your monitoring alerts on real problems, your API clients handle errors gracefully, and your debugging sessions are shorter because the status code already tells you the category of the problem before you even open the logs.

The rules are straightforward. Use 201 when you create something, not 200. Use 204 when there is nothing to return. Use 401 for missing or invalid authentication and 403 for insufficient permissions, never the other way around. Use 422 for validation failures instead of cramming everything into 400. Use 429 with a Retry-After header for rate limiting. And never, under any circumstances, return 200 OK with an error in the body.

If you take only one thing from this guide, let it be this: status codes are a contract. Your API consumers, your infrastructure, and your monitoring tools all depend on that contract being honored. Every time you return the wrong status code, you are breaking a promise that propagates through layers of software you do not control. Keeping that promise is not difficult — it just requires the discipline to think about the semantics of each response instead of defaulting to 200 and calling it a day.

The complete reference table in this guide covers every code you will need for the vast majority of web applications and APIs. Bookmark it, share it with your team, and refer to it during code reviews. Consistent, correct status code usage is one of the smallest changes you can make that has the largest impact on the quality and reliability of your API.

Debug API responses with our free JSON Formatter tool. Paste any JSON response and instantly see it pretty-printed, validated, and syntax-highlighted.

Open JSON Formatter Tool