Why I Studied These Numbers
I was building an API for my first project. A developer in my community asked me, "What should we send when login fails? 401? 403?" I just answered, "If it's an error, isn't it all 400?" The moment I said that, their expression became subtle. I didn't really understand HTTP status codes.
The frontend developer kept asking me, "Why is there an error message when it's 200?" My API always sent 200 and put something like { success: false, error: "..." } in the body. That's when I realized I shouldn't ignore status codes.
Ultimately, this study was about properly understanding "the traffic lights the server sends to the client." I didn't know how much information those three digits could contain.
What Confused Me at First
Can't I Just Send Everything as 200?
That was my thinking as a beginner. "If the communication succeeded anyway, send 200 and put the error in the JSON body, right?" Some APIs were actually designed that way. But this was ignoring HTTP's philosophy.
Browsers, proxies, load balancers, monitoring systems all look at status codes. If everything is 200, they judge it as "no problem." Even if my server is actually spitting out 500 errors, external systems record it as "normal." The beginning of a debugging nightmare.
The Difference Between 401 and 403
This really confused me. Both mean "access denied," so why separate them?
I understood it this way. 401 is "I don't know who you are, show me your ID," and 403 is "I verified your identity, but you can't come in here."
The convenience store analogy resonated with me. When you go to buy cigarettes, the clerk checks your ID. If you don't show ID? 401 Unauthorized. If they confirm you're a minor and can't sell? 403 Forbidden. Identity is verified but no permission.
The Subtle Difference Between 301 and 302
Both are redirects, so why two? I thought about this for a while too.
301 is "permanent move." When the browser sees this, it thinks "Oh, this address has changed to there now" and remembers it. Next time it goes directly to the new address. 302 is "temporary move." "Go here for now, but the original address is still valid. Check again later."
I accepted the company relocation analogy. If you completely move headquarters, it's 301. If you temporarily use another building during construction, it's 302.
Aha Moment: Status Codes Were a Contract
One day I realized. HTTP status codes are a contract between client and server.
"I'll send this number in this situation. When you see this number, process it like this." It's this kind of promise. When this promise is kept, everything becomes clear. Frontend developers automatically judge when they see 401, "Oh, I should send to login page," when they see 404, "I should show a Not Found page."
If you don't use status codes properly, this contract breaks. If all responses are 200, the client has to open the body every time. Caching doesn't work, and error handling becomes complex.
Deep Dive: The Real Meaning of Each Status Code
1xx - Informational: The Ones Nobody Uses?
Honestly, I've rarely seen 1xx in practice. Theoretically it means "processing, wait."
- 100 Continue: Used when client asks "I'm going to send big data, is that okay?" and server says "yeah send it"
- 101 Switching Protocols: Used when switching from HTTP to WebSocket
Most developers never directly deal with 1xx. Browsers and web servers handle it automatically.
2xx - Success: There Are Types of Success
200 OK - The Most Common Success
When GET request succeeds, 200. Data is returned in the response body. This is the most basic.
// GET /api/users/123
fetch('/api/users/123')
.then(res => {
console.log(res.status); // 200
return res.json();
})
.then(data => console.log(data));
201 Created - Something Was Created
When you create a resource with POST, send 201. It's a clear signal of "received well and created new."
// POST /api/posts
fetch('/api/posts', {
method: 'POST',
body: JSON.stringify({ title: 'New Post' })
})
.then(res => {
console.log(res.status); // 201
// New resource URL comes in Location header
console.log(res.headers.get('Location')); // /api/posts/456
});
At first I thought "can't I just send 200?" but when you receive 201, the client immediately recognizes "oh, it's a new resource." Caching policy also differs.
204 No Content - Succeeded But Nothing to Return
When DELETE succeeds, 204 is often used. "Deletion complete. But there's no data to show."
// DELETE /api/posts/456
fetch('/api/posts/456', { method: 'DELETE' })
.then(res => {
console.log(res.status); // 204
console.log(res.body); // null
});
204 has no body. At first this felt awkward, but thinking about it, what would you return after deleting? Even a "deletion successful" message is unnecessary. 204 itself is that message.
3xx - Redirection: Go Somewhere Else
301 Moved Permanently - Permanent Move
Used when URL structure changes. If you completely moved /old-page to /new-page, use 301.
// Server response: 301, Location: /new-page
fetch('/old-page')
.then(res => {
console.log(res.status); // 301
console.log(res.redirected); // true
console.log(res.url); // /new-page (browser follows automatically)
});
Browsers cache 301. Next time they change the /old-page request to /new-page entirely. Server load decreases.
302 Found / 307 Temporary Redirect - Temporary Move
When sending a user who isn't logged in to the login page, use 302.
// When accessing a page that requires login
// Server: 302, Location: /login?redirect=/dashboard
The difference between 302 and 307 is subtle. 302 can change POST request to GET, but 307 maintains the method. I mainly use 302.
304 Not Modified - Use Cache
When client asks "Is the version I have the latest?" and server says "yeah use that," it's 304.
// On second request
fetch('/api/data', {
headers: { 'If-None-Match': 'etag-12345' }
})
.then(res => {
console.log(res.status); // 304
// Browser uses cached data
});
304 tremendously reduces traffic. Because it doesn't send the body. When setting up caching strategy in practice, if you use 304 properly, server load drops significantly.
4xx - Client Error: It's Your Fault
400 Bad Request - You Sent Something Weird
When request format is wrong. JSON parsing failure, missing required fields, etc.
// Invalid JSON
fetch('/api/users', {
method: 'POST',
body: '{ invalid json }'
})
.then(res => console.log(res.status)); // 400
I also use 400 for validation failures. "Not email format," "password too short," things like that.
401 Unauthorized - Log In
When authentication is needed but not done.
fetch('/api/private-data')
.then(res => {
if (res.status === 401) {
// Redirect to login page
window.location.href = '/login';
}
});
I globally handle 401 with axios interceptor.
axios.interceptors.response.use(
response => response,
error => {
if (error.response?.status === 401) {
localStorage.removeItem('token');
window.location.href = '/login';
}
return Promise.reject(error);
}
);
This way all 401 responses are handled in one place. Code duplication disappeared.
403 Forbidden - No Permission
When logged in but no permission.
// Regular user accessing admin page
fetch('/api/admin/users')
.then(res => {
if (res.status === 403) {
alert('You do not have permission');
}
});
In practice, 403 and 401 are often confused. I distinguish them this way. No token → 401, Has token but insufficient permission → 403.
404 Not Found - No Such Thing
When URL is wrong or resource doesn't exist. The most famous error.
fetch('/api/users/99999')
.then(res => {
if (res.status === 404) {
console.log('User not found');
}
});
I use 404 in two meanings. Endpoint itself doesn't exist (routing failure), Resource cannot be found (ID not in DB). Some people send the latter as 200 and put { found: false } in body. I think 404 is clearer.
405 Method Not Allowed - That Method Won't Work
When only GET is allowed but POST was sent.
fetch('/api/read-only-resource', { method: 'DELETE' })
.then(res => console.log(res.status)); // 405
Not used much in practice. Most are handled as 404. But if you design RESTful API strictly, 405 is meaningful.
409 Conflict - There's a Conflict
When attempting to create duplicate data.
// Signing up with existing email
fetch('/api/signup', {
method: 'POST',
body: JSON.stringify({ email: 'existing@example.com' })
})
.then(res => {
if (res.status === 409) {
alert('Email already in use');
}
});
At first I used 400, but 409 is more specific. The nuance of "format is correct, but there's a conflict."
429 Too Many Requests - Don't Send Too Many
When rate limiting is hit.
fetch('/api/data')
.then(res => {
if (res.status === 429) {
const retryAfter = res.headers.get('Retry-After');
console.log(`Retry after ${retryAfter} seconds`);
}
});
I use this for bot defense. If 100 requests come in 1 second, send 429. Client waits looking at Retry-After header.
5xx - Server Error: It's My Fault
500 Internal Server Error - My Code Crashed
When there's a bug in server code. The scariest error.
fetch('/api/something')
.then(res => {
if (res.status === 500) {
alert('Server error occurred. Please try again later.');
}
});
In practice, 500 is the "call developer" signal. When monitoring system detects 500, alert comes immediately. I track 500 errors with tools like Sentry.
502 Bad Gateway - Backend Server Died
When proxy or gateway can't get response from upstream server.
// Nginx can't connect to backend server
fetch('/api/data')
.then(res => console.log(res.status)); // 502
This isn't my code problem but infrastructure problem. Backend server is down, or network failure.
503 Service Unavailable - Can't Service Right Now
When server is temporarily overloaded.
fetch('/api/data')
.then(res => {
if (res.status === 503) {
const retryAfter = res.headers.get('Retry-After');
console.log('Server is overloaded');
}
});
I send 503 during deployment. When health check fails, load balancer doesn't send traffic to that server.
504 Gateway Timeout - Backend Server Not Responding
Similar to 502, but connected but response too slow.
// Backend doesn't respond for 30 seconds
fetch('/api/slow-query')
.then(res => console.log(res.status)); // 504
I see this often when heavy queries are sent. This is also an infrastructure issue.
Application: Real-World Code Examples
Example 1: Error Handling Based on Status Codes
I created a fetch wrapper to handle each status code.
async function apiFetch(url, options = {}) {
try {
const response = await fetch(url, options);
// 2xx success
if (response.ok) {
// 204 has no body
if (response.status === 204) {
return null;
}
return await response.json();
}
// 4xx client error
if (response.status >= 400 && response.status < 500) {
const errorData = await response.json();
switch (response.status) {
case 400:
throw new Error(`Bad request: ${errorData.message}`);
case 401:
// Redirect to login page
localStorage.removeItem('token');
window.location.href = '/login';
return;
case 403:
throw new Error('No permission');
case 404:
throw new Error('Resource not found');
case 409:
throw new Error(`Conflict: ${errorData.message}`);
case 429:
const retryAfter = response.headers.get('Retry-After');
throw new Error(`Too many requests. Retry after ${retryAfter} seconds`);
default:
throw new Error(`Client error (${response.status})`);
}
}
// 5xx server error
if (response.status >= 500) {
// Report to error tracking service
logErrorToService({
status: response.status,
url,
timestamp: new Date()
});
switch (response.status) {
case 502:
case 503:
case 504:
throw new Error('Server is temporarily unstable. Please try again later');
default:
throw new Error('Server error occurred');
}
}
} catch (error) {
if (error instanceof TypeError) {
// Network error (server connection failed)
throw new Error('Please check network connection');
}
throw error;
}
}
// Usage example
try {
const users = await apiFetch('/api/users');
console.log(users);
} catch (error) {
console.error(error.message);
}
Using this pattern, debugging time was cut in half. Error messages became clear.
Example 2: Sending Correct Status Codes from Server
When building API with Express, I set status codes properly.
const express = require('express');
const app = express();
// Create user
app.post('/api/users', async (req, res) => {
const { email, name } = req.body;
// validation
if (!email || !name) {
return res.status(400).json({
error: 'Email and name are required'
});
}
// duplicate check
const existingUser = await db.findUserByEmail(email);
if (existingUser) {
return res.status(409).json({
error: 'Email already in use'
});
}
try {
const newUser = await db.createUser({ email, name });
// 201 Created + Location header
res.status(201)
.location(`/api/users/${newUser.id}`)
.json(newUser);
} catch (error) {
console.error('User creation failed:', error);
res.status(500).json({
error: 'Server error occurred'
});
}
});
// Get user
app.get('/api/users/:id', async (req, res) => {
try {
const user = await db.findUserById(req.params.id);
if (!user) {
return res.status(404).json({
error: 'User not found'
});
}
res.status(200).json(user);
} catch (error) {
res.status(500).json({
error: 'Server error occurred'
});
}
});
// Delete user
app.delete('/api/users/:id', async (req, res) => {
// Auth check
if (!req.user) {
return res.status(401).json({
error: 'Login required'
});
}
// Permission check
if (req.user.id !== req.params.id && !req.user.isAdmin) {
return res.status(403).json({
error: 'No permission to delete'
});
}
try {
const deleted = await db.deleteUser(req.params.id);
if (!deleted) {
return res.status(404).json({
error: 'User not found'
});
}
// 204 No Content (no body)
res.status(204).send();
} catch (error) {
res.status(500).json({
error: 'Server error occurred'
});
}
});
// Rate limiting
const rateLimit = require('express-rate-limit');
const limiter = rateLimit({
windowMs: 60 * 1000, // 1 minute
max: 100, // max 100 requests
handler: (req, res) => {
res.status(429)
.set('Retry-After', '60')
.json({
error: 'Too many requests. Retry after 1 minute'
});
}
});
app.use('/api/', limiter);
After introducing this code, frontend developers became happy. "Error handling is clear!"
Example 3: Retry Logic with Status Codes
Retry temporary errors.
async function fetchWithRetry(url, options = {}, maxRetries = 3) {
for (let i = 0; i < maxRetries; i++) {
try {
const response = await fetch(url, options);
// Return immediately if successful
if (response.ok) {
return response;
}
// Check if it's a retryable error
const shouldRetry =
response.status === 429 || // Rate limit
response.status === 502 || // Bad Gateway
response.status === 503 || // Service Unavailable
response.status === 504; // Gateway Timeout
if (!shouldRetry) {
// Non-retryable error (4xx etc)
return response;
}
// Check Retry-After header
const retryAfter = response.headers.get('Retry-After');
const waitTime = retryAfter
? parseInt(retryAfter) * 1000
: Math.pow(2, i) * 1000; // Exponential backoff
console.log(`Retry ${i + 1}/${maxRetries}, waiting ${waitTime}ms...`);
await new Promise(resolve => setTimeout(resolve, waitTime));
} catch (error) {
// Retry network errors
if (i === maxRetries - 1) {
throw error;
}
console.log(`Network error, retry ${i + 1}/${maxRetries}`);
await new Promise(resolve => setTimeout(resolve, 1000 * (i + 1)));
}
}
}
// Usage example
const response = await fetchWithRetry('/api/important-data');
if (response.ok) {
const data = await response.json();
console.log(data);
}
Thanks to this pattern, errors from temporary network issues decreased by 90%.
How This Knowledge Saved Me
Reduced Debugging Time
Before, when reports came in saying "API isn't working," I had to dig through logs. Now I just look at the status code.
- 401? Check token expiration
- 403? Check permission settings
- 500? Check server logs
- 502? Call infrastructure person
Problem area is immediately narrowed.
Clear API Design
When discussing API specs, the worry "what should we send in this case?" disappeared. Just follow HTTP standards. New collaborators understand quickly too.
Automated Monitoring
I set up alerts by status code.
- 4xx ratio surge → suspect client bug
- 5xx occurrence → immediate Slack alert
- 503 → trigger scale out
Before I checked manually, now the system judges automatically.
Summary: This Was It After All
HTTP status codes are a standardized means of communication. They make server and client speak the same language.
Core Summary:
- 2xx: Success. 200(general), 201(created), 204(no content)
- 3xx: Redirect. 301(permanent), 302(temporary), 304(use cache)
- 4xx: Client fault. 400(bad request), 401(auth needed), 403(no permission), 404(not found), 409(conflict), 429(too many requests)
- 5xx: Server fault. 500(server error), 502(gateway error), 503(service unavailable), 504(timeout)
Principles I Accepted:
- Send appropriate status code in all responses (prohibit 200-for-everything)
- Classify errors by status code (4xx vs 5xx)
- Client branches processing logic based on status code
- Retry temporary errors (429, 502, 503)
These three digits cut my debugging time in half. When I use HTTP properly, everything becomes clear.