
HTTP Status Codes
Traffic lights from Server. 200 Success, 400 You failed, 500 I failed.

Traffic lights from Server. 200 Success, 400 You failed, 500 I failed.
Why does my server crash? OS's desperate struggle to manage limited memory. War against Fragmentation.

Two ways to escape a maze. Spread out wide (BFS) or dig deep (DFS)? Who finds the shortest path?

A comprehensive deep dive into client-side storage. From Cookies to IndexedDB and the Cache API. We explore security best practices for JWT storage (XSS vs CSRF), performance implications of synchronous APIs, and how to build offline-first applications using Service Workers.

Fast by name. Partitioning around a Pivot. Why is it the standard library choice despite O(N²) worst case?

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.
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.
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.
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.
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.
Honestly, I've rarely seen 1xx in practice. Theoretically it means "processing, wait."
Most developers never directly deal with 1xx. Browsers and web servers handle it automatically.
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));
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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."
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.
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.
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.
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.
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.
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.
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!"
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%.
Before, when reports came in saying "API isn't working," I had to dig through logs. Now I just look at the status code.
Problem area is immediately narrowed.
When discussing API specs, the worry "what should we send in this case?" disappeared. Just follow HTTP standards. New collaborators understand quickly too.
I set up alerts by status code.
Before I checked manually, now the system judges automatically.
HTTP status codes are a standardized means of communication. They make server and client speak the same language.
Core Summary:These three digits cut my debugging time in half. When I use HTTP properly, everything becomes clear.