HTTP Status Codes: Complete Reference Guide

HTTP status codes are three-digit integers that servers return to communicate the outcome of a request. They're not just informational—they're a contract between client and server that enables...

Key Insights

  • HTTP status codes are semantic signals that enable clients to handle responses programmatically—returning 200 with error messages in the body breaks this contract and forces clients to parse response bodies for every request
  • The difference between 4xx and 5xx isn’t just technical—4xx means the client should fix their request before retrying, while 5xx means the server failed and a retry might succeed
  • Modern applications should implement status-code-aware retry logic: automatically retry 503s and 429s with backoff, never retry 4xx errors, and log 5xx errors for investigation

Introduction to HTTP Status Codes

HTTP status codes are three-digit integers that servers return to communicate the outcome of a request. They’re not just informational—they’re a contract between client and server that enables programmatic decision-making without parsing response bodies.

Status codes fall into five classes:

  • 1xx (Informational): Request received, continuing process
  • 2xx (Success): Request successfully received, understood, and accepted
  • 3xx (Redirection): Further action needed to complete the request
  • 4xx (Client Error): Request contains bad syntax or cannot be fulfilled
  • 5xx (Server Error): Server failed to fulfill a valid request

Here’s how you typically encounter status codes in JavaScript:

async function fetchUser(userId) {
  const response = await fetch(`/api/users/${userId}`);
  
  if (!response.ok) {
    if (response.status === 404) {
      throw new Error('User not found');
    } else if (response.status >= 500) {
      throw new Error('Server error, please try again');
    }
    throw new Error(`Request failed: ${response.status}`);
  }
  
  return response.json();
}

Success Codes (2xx)

The 2xx family indicates success, but different codes communicate different types of success.

200 OK is the standard success response. Use it for successful GET requests, successful updates that return data, and any operation that completes successfully with a response body.

201 Created specifically indicates a new resource was created. Always include a Location header pointing to the new resource.

204 No Content means success with no response body. Use it for DELETE operations or updates that don’t need to return the updated resource.

Here’s how to use them correctly in Express:

const express = require('express');
const app = express();

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

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

// 204 No Content - Successful deletion
app.delete('/api/users/:id', async (req, res) => {
  await db.users.delete(req.params.id);
  res.status(204).send();
});

Redirection Codes (3xx)

Redirects tell clients to look elsewhere for the resource. The key distinction is between permanent and temporary redirects, and whether to preserve the HTTP method.

301 Moved Permanently and 308 Permanent Redirect both indicate permanent moves. The difference: 301 allows the client to change POST to GET on redirect, while 308 guarantees method preservation.

302 Found and 307 Temporary Redirect indicate temporary moves with the same method-preservation distinction.

304 Not Modified is special—it’s used with conditional requests to indicate cached content is still valid.

// Express redirect handling
app.get('/old-api/users', (req, res) => {
  // Permanent redirect, method may change
  res.redirect(301, '/api/v2/users');
});

app.post('/api/submit', (req, res) => {
  // Temporary redirect, preserve POST method
  res.redirect(307, '/api/v2/submit');
});

// Client-side redirect handling
async function fetchWithRedirect(url) {
  const response = await fetch(url, {
    redirect: 'follow' // default, automatically follows redirects
  });
  
  // Check if we were redirected
  if (response.redirected) {
    console.log('Redirected to:', response.url);
  }
  
  return response.json();
}

Client Error Codes (4xx)

Client errors indicate the request was invalid. The client should not retry without modification.

400 Bad Request is the generic “your request is malformed” response. Use it for invalid JSON, missing required headers, or malformed parameters.

401 Unauthorized means authentication is required or failed. Include a WWW-Authenticate header.

403 Forbidden means the server understood the request but refuses to authorize it. The user is authenticated but lacks permissions.

404 Not Found means the resource doesn’t exist at this URL.

422 Unprocessable Entity is crucial for validation errors. The request was well-formed but semantically invalid.

429 Too Many Requests indicates rate limiting. Include Retry-After header.

// Custom error handling middleware
class AppError extends Error {
  constructor(message, statusCode) {
    super(message);
    this.statusCode = statusCode;
    this.isOperational = true;
  }
}

app.post('/api/users', async (req, res, next) => {
  try {
    // Validation errors -> 422
    const errors = validateUser(req.body);
    if (errors.length > 0) {
      return res.status(422).json({ 
        error: 'Validation failed',
        details: errors 
      });
    }
    
    // Check rate limit -> 429
    const isRateLimited = await checkRateLimit(req.ip);
    if (isRateLimited) {
      return res.status(429)
                .set('Retry-After', '60')
                .json({ error: 'Too many requests' });
    }
    
    const user = await db.users.create(req.body);
    res.status(201).json(user);
  } catch (error) {
    next(error);
  }
});

// Error handling middleware
app.use((err, req, res, next) => {
  if (err.isOperational) {
    return res.status(err.statusCode).json({ error: err.message });
  }
  
  // Programmer errors -> 500
  console.error('Unexpected error:', err);
  res.status(500).json({ error: 'Internal server error' });
});

Server Error Codes (5xx)

Server errors indicate the server failed to fulfill a valid request. These are retryable errors—the same request might succeed later.

500 Internal Server Error is the generic server error. Use it for unexpected exceptions.

502 Bad Gateway indicates the server, acting as a gateway, received an invalid response from an upstream server.

503 Service Unavailable means the server is temporarily unable to handle the request. Always include a Retry-After header.

The critical distinction: if the client sent a bad request, return 4xx. If your server or its dependencies failed, return 5xx.

app.use(async (err, req, res, next) => {
  // Operational errors we expect
  if (err.isOperational) {
    return res.status(err.statusCode).json({ 
      error: err.message 
    });
  }
  
  // Database connection errors -> 503
  if (err.name === 'SequelizeConnectionError') {
    return res.status(503)
              .set('Retry-After', '30')
              .json({ error: 'Service temporarily unavailable' });
  }
  
  // Unexpected errors -> 500
  console.error('Unhandled error:', err);
  res.status(500).json({ 
    error: 'Internal server error' 
  });
});

Practical Implementation Patterns

Build reusable utilities for consistent status code handling:

// Axios interceptor with intelligent retry logic
const axios = require('axios');

const apiClient = axios.create({
  baseURL: 'https://api.example.com'
});

apiClient.interceptors.response.use(
  response => response,
  async error => {
    const { config, response } = error;
    
    // Don't retry if no response or already retried max times
    if (!response || config._retryCount >= 3) {
      return Promise.reject(error);
    }
    
    config._retryCount = config._retryCount || 0;
    
    // Retry on 503 Service Unavailable
    if (response.status === 503) {
      const retryAfter = response.headers['retry-after'] || 5;
      await new Promise(resolve => 
        setTimeout(resolve, retryAfter * 1000)
      );
      config._retryCount++;
      return apiClient(config);
    }
    
    // Retry on 429 Too Many Requests with exponential backoff
    if (response.status === 429) {
      const backoff = Math.pow(2, config._retryCount) * 1000;
      await new Promise(resolve => setTimeout(resolve, backoff));
      config._retryCount++;
      return apiClient(config);
    }
    
    // Never retry 4xx client errors
    if (response.status >= 400 && response.status < 500) {
      return Promise.reject(error);
    }
    
    // Retry 5xx server errors with backoff
    if (response.status >= 500) {
      const backoff = Math.pow(2, config._retryCount) * 1000;
      await new Promise(resolve => setTimeout(resolve, backoff));
      config._retryCount++;
      return apiClient(config);
    }
    
    return Promise.reject(error);
  }
);

Common Mistakes and Best Practices

Mistake #1: Returning 200 with errors in the body

// WRONG - Don't do this
app.post('/api/users', (req, res) => {
  if (!req.body.email) {
    return res.status(200).json({ 
      success: false, 
      error: 'Email required' 
    });
  }
});

// RIGHT - Use proper status codes
app.post('/api/users', (req, res) => {
  if (!req.body.email) {
    return res.status(422).json({ 
      error: 'Email required' 
    });
  }
});

Mistake #2: Using 500 for validation errors

If the client sent invalid data, that’s a 4xx error. Reserve 5xx for actual server failures.

Testing status codes properly:

const request = require('supertest');
const app = require('./app');

describe('POST /api/users', () => {
  test('returns 201 when user created successfully', async () => {
    const response = await request(app)
      .post('/api/users')
      .send({ email: 'test@example.com', name: 'Test' });
    
    expect(response.status).toBe(201);
    expect(response.headers.location).toMatch(/\/api\/users\/\d+/);
  });
  
  test('returns 422 for validation errors', async () => {
    const response = await request(app)
      .post('/api/users')
      .send({ email: 'invalid' });
    
    expect(response.status).toBe(422);
    expect(response.body.error).toBeDefined();
  });
  
  test('returns 429 when rate limited', async () => {
    // Make multiple requests to trigger rate limit
    for (let i = 0; i < 100; i++) {
      await request(app).post('/api/users').send({});
    }
    
    const response = await request(app)
      .post('/api/users')
      .send({ email: 'test@example.com' });
    
    expect(response.status).toBe(429);
    expect(response.headers['retry-after']).toBeDefined();
  });
});

Use status codes as they were intended: as semantic signals that enable robust, self-healing distributed systems. Your clients will thank you, and your monitoring dashboards will be far more useful when a spike in 503s means something different than a spike in 422s.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.