Understanding HTTP Methods and Status Codes
You call an API, the request "works", and the response is 200 OK with {"success": false} in the body. Your error handling never fires because it only checks response.ok. Or a form submit retries on a flaky connection and you end up with two orders in the database. Both bugs come from treating HTTP methods and status codes as details instead of a contract between client and server.
This post goes through the methods you'll actually use (GET, POST, PUT, PATCH, DELETE, plus HEAD and OPTIONS), what safe and idempotent mean in practice, the status codes worth knowing in each range, and a small Express API with a client that handles the responses properly.
- All HTTP methods (GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS) with real examples
- Status codes decoded: 1xx, 2xx, 3xx, 4xx, 5xx and when to use each
- RESTful API design patterns and best practices
- Idempotency, safety, and cacheability of methods
- Practical code examples in JavaScript, Node.js, and cURL
- Common mistakes and how to avoid them
The HTTP Request-Response Cycle
Every HTTP interaction involves a request from the client and a response from the server. The method tells the server what action to perform, and the status code tells the client what happened.
The HTTP cycle: Client sends a request with a method, server returns a response with a status code
HTTP Methods Explained
The method (people often call it the "verb") says what you want done to the resource at that URL. HTTP defines a handful more, but these five cover nearly every API you'll build:
HTTP methods map to CRUD operations: Create (POST), Read (GET), Update (PUT/PATCH), Delete (DELETE)
GET - Retrieve Data
GET Method Details
GET fetches a resource. It's the request your browser makes for every page load, and it should only ever read data. Never change state in a GET handler: crawlers, link prefetchers and browser preloading will happily call it for you.
- Safe: Does not modify server state
- Idempotent: Multiple identical requests produce the same result
- Cacheable: Responses can be cached by browsers and CDNs
- No body: Data sent via URL parameters (query strings)
// JavaScript Fetch API
const response = await fetch('/api/users');
const users = await response.json();
// With query parameters
const response = await fetch('/api/users?page=1&limit=10');
// Get single resource
const user = await fetch('/api/users/123');
// cURL
curl -X GET "https://api.example.com/users" -H "Accept: application/json"
POST - Create Data
POST Method Details
The POST method submits data to be processed by the server, typically resulting in a new resource being created or a state change on the server.
- Not Safe: Modifies server state
- Not Idempotent: Multiple requests may create multiple resources
- Not Cacheable: By default (can be made cacheable with headers)
- Has body: Data sent in request body (JSON, form data, etc.)
// JavaScript Fetch API - Create a user
const response = await fetch('/api/users', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
name: 'John Doe',
email: 'john@example.com'
})
});
const newUser = await response.json();
// Response: { id: 124, name: 'John Doe', email: 'john@example.com' }
// cURL
curl -X POST "https://api.example.com/users" \
-H "Content-Type: application/json" \
-d '{"name":"John Doe","email":"john@example.com"}'
PUT - Replace Data
PUT Method Details
The PUT method replaces the entire resource with the provided data. If the resource doesn't exist, PUT may create it (though this depends on server implementation).
- Not Safe: Modifies server state
- Idempotent: Multiple identical requests produce the same result
- Replaces entire resource: All fields must be provided
- Can create: If resource doesn't exist (server-dependent)
// Replace entire user resource
const response = await fetch('/api/users/123', {
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
id: 123,
name: 'John Updated',
email: 'john.updated@example.com',
role: 'admin' // Must include ALL fields
})
});
PATCH - Partial Update
PATCH Method Details
The PATCH method applies partial modifications to a resource. Unlike PUT, you only send the fields that need to be updated.
- Not Safe: Modifies server state
- Not Always Idempotent: Depends on implementation
- Partial update: Only changed fields are sent
- More efficient: Smaller payloads than PUT
// Update only the email field
const response = await fetch('/api/users/123', {
method: 'PATCH',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
email: 'new.email@example.com' // Only send what changes
})
});
DELETE - Remove Data
DELETE Method Details
The DELETE method removes the specified resource from the server.
- Not Safe: Modifies server state
- Idempotent: Deleting twice has same effect as once
- Usually no body: Resource identified by URL
- Returns: 200 OK, 202 Accepted, or 204 No Content
// Delete a user
const response = await fetch('/api/users/123', {
method: 'DELETE'
});
if (response.status === 204) {
console.log('User deleted successfully');
}
// cURL
curl -X DELETE "https://api.example.com/users/123"
Other HTTP Methods
HEAD, OPTIONS, TRACE, CONNECT
HEAD - Same as GET but returns only headers, not the body. Useful for checking if a resource exists or getting metadata.
curl -I "https://api.example.com/users/123"
OPTIONS - Returns the allowed HTTP methods for a resource. Used in CORS preflight requests.
curl -X OPTIONS "https://api.example.com/users" -i
# Response Header: Allow: GET, POST, PUT, DELETE, OPTIONS
TRACE - Echoes the received request for debugging. Usually disabled for security reasons.
CONNECT - Establishes a tunnel to the server, typically used for HTTPS through HTTP proxies.
Method Properties Comparison
These three properties decide what caches, proxies and retry logic are allowed to do with a request, so they matter more than the names:
| Method | Safe | Idempotent | Cacheable | Request Body |
|---|---|---|---|---|
| GET | β Yes | β Yes | β Yes | β No |
| POST | β No | β No | β οΈ Conditional | β Yes |
| PUT | β No | β Yes | β No | β Yes |
| PATCH | β No | β οΈ Not guaranteed | β No | β Yes |
| DELETE | β No | β Yes | β No | β οΈ Optional |
Idempotent means that making the same request multiple times leaves the server in the same state as making it once. That's what lets a client retry after a timeout without worrying about side effects:
- GET /users/123 β Always returns the same user (idempotent)
- DELETE /users/123 β First deletes, subsequent calls return 404 (same final state = idempotent)
- POST /users β Each call creates a new user (NOT idempotent)
HTTP Status Codes Decoded
The first digit tells you who has to act. 2xx means it worked, 3xx means look elsewhere, 4xx means the client sent something wrong, and 5xx means the server broke. If you only remember one thing about status codes, make it that.
The five categories of HTTP status codes and what they mean
2xx Success Codes
2xx Success - Request Succeeded
3xx Redirection Codes
3xx Redirection - Further Action Needed
4xx Client Error Codes
4xx Client Error - Problem with Request
- 401 Unauthorized: "Who are you?" - No authentication provided or invalid credentials
- 403 Forbidden: "I know who you are, but no." - Authenticated but lacking permission
5xx Server Error Codes
5xx Server Error - Something Went Wrong
RESTful API Design Patterns
Here's how methods and codes line up for a typical users resource. Notice the URLs stay the same and only the method changes:
// Standard REST endpoints for a Users resource
GET /api/users β 200 OK (list all users)
GET /api/users/123 β 200 OK (get single user) | 404 Not Found
POST /api/users β 201 Created (create user) | 400/422 (validation error)
PUT /api/users/123 β 200 OK (replace user) | 404 Not Found
PATCH /api/users/123 β 200 OK (update fields) | 404 Not Found
DELETE /api/users/123 β 204 No Content (deleted) | 404 Not Found
// Nested resources
GET /api/users/123/posts β 200 OK (user's posts)
POST /api/users/123/posts β 201 Created (create post for user)
// Actions (non-CRUD operations)
POST /api/users/123/activate β 200 OK (activate user)
POST /api/users/123/deactivate β 200 OK (deactivate user)
Complete API Example
const express = require('express');
const app = express();
app.use(express.json());
let users = [
{ id: 1, name: 'John', email: 'john@example.com' }
];
// GET all users
app.get('/api/users', (req, res) => {
res.status(200).json({ data: users });
});
// GET single user
app.get('/api/users/:id', (req, res) => {
const user = users.find(u => u.id === parseInt(req.params.id));
if (!user) {
return res.status(404).json({ error: 'User not found' });
}
res.status(200).json({ data: user });
});
// POST create user
app.post('/api/users', (req, res) => {
const { name, email } = req.body;
if (!name || !email) {
return res.status(400).json({ error: 'Name and email required' });
}
const newUser = { id: users.length + 1, name, email };
users.push(newUser);
res.status(201).json({ data: newUser });
});
// PATCH update user
app.patch('/api/users/:id', (req, res) => {
const user = users.find(u => u.id === parseInt(req.params.id));
if (!user) {
return res.status(404).json({ error: 'User not found' });
}
Object.assign(user, req.body);
res.status(200).json({ data: user });
});
// DELETE user
app.delete('/api/users/:id', (req, res) => {
const index = users.findIndex(u => u.id === parseInt(req.params.id));
if (index === -1) {
return res.status(404).json({ error: 'User not found' });
}
users.splice(index, 1);
res.status(204).send();
});
app.listen(3000);
// API wrapper with proper error handling
class ApiClient {
constructor(baseUrl) {
this.baseUrl = baseUrl;
}
async request(method, endpoint, data = null) {
const options = {
method,
headers: { 'Content-Type': 'application/json' }
};
if (data && ['POST', 'PUT', 'PATCH'].includes(method)) {
options.body = JSON.stringify(data);
}
const response = await fetch(`${this.baseUrl}${endpoint}`, options);
// Handle different status codes
if (response.status === 204) return null;
const json = await response.json();
if (!response.ok) {
throw new Error(json.error || `HTTP ${response.status}`);
}
return json.data;
}
getUsers() { return this.request('GET', '/users'); }
getUser(id) { return this.request('GET', `/users/${id}`); }
createUser(data) { return this.request('POST', '/users', data); }
updateUser(id, data) { return this.request('PATCH', `/users/${id}`, data); }
deleteUser(id) { return this.request('DELETE', `/users/${id}`); }
}
// Usage
const api = new ApiClient('/api');
const users = await api.getUsers();
const newUser = await api.createUser({ name: 'Jane', email: 'jane@example.com' });
async function fetchWithRetry(url, options, retries = 3) {
for (let i = 0; i < retries; i++) {
try {
const response = await fetch(url, options);
// Handle different status code categories
switch (true) {
case response.status >= 200 && response.status < 300:
return response; // Success!
case response.status === 401:
// Refresh token and retry
await refreshAuthToken();
continue;
case response.status === 429:
// Rate limited - wait and retry
const retryAfter = response.headers.get('Retry-After') || 5;
await delay(retryAfter * 1000);
continue;
case response.status >= 500:
// Server error - retry with backoff
if (i < retries - 1) {
await delay(Math.pow(2, i) * 1000);
continue;
}
throw new Error(`Server error: ${response.status}`);
default:
// Client errors (4xx) - don't retry
const error = await response.json();
throw new Error(error.message || `HTTP ${response.status}`);
}
} catch (err) {
if (i === retries - 1) throw err;
}
}
}
const delay = ms => new Promise(r => setTimeout(r, ms));
Quick Reference Chart
In practice, these twelve cover most of what a typical API sends back:
Common Mistakes to Avoid
- Using GET for mutations: GET /api/users/123/delete. Use DELETE instead
- Returning 200 for errors: Always use appropriate error codes (4xx/5xx)
- Using 404 for authorization: Use 403 Forbidden for permission issues (unless you're deliberately hiding that the resource exists)
- Ignoring idempotency: POST should create; PUT should be repeatable
- Generic 500 for everything: Use specific codes (400, 422, 409) for client errors
- Use nouns, not verbs: /api/users not /api/getUsers
- Consistent error format: { "error": "message", "code": "ERROR_CODE" }
- Include Location header: With 201 Created, point to the new resource
- Use 202 Accepted: For async operations that will complete later
- Document your API: Use OpenAPI/Swagger for clear documentation
Gotchas I'd Check Before Shipping an API
The tables above are the easy part. These are the things that tend to break once real clients, proxies and load balancers get involved:
- Calling
response.json()on a 204. A 204 has no body, so parsing it throwsSyntaxError: Unexpected end of JSON input. That's why the client above returns early on 204. The same error shows up when a proxy returns an HTML 502 page and your code assumes JSON, so check the status (orContent-Type) before parsing. - Redirecting a POST with 301 or 302. Browsers and most HTTP clients turn the follow-up request into a GET and drop the body. If you need the method and body preserved, use 307 or 308.
- Retrying POST blindly. The
fetchWithRetryexample retries on any 5xx, which is fine for GET but can create duplicates for POST if the server did the work and then timed out. For payments or orders, send an idempotency key header and have the server deduplicate, or don't retry POST at all. - 401 without
WWW-Authenticate. The spec says a 401 response should include that header. Most JSON APIs skip it and nothing breaks, but some clients and tools expect it.
I wouldn't chase "pure REST" too hard, though. If an operation doesn't map to a resource (sending a password reset email, running a report), a POST /api/users/123/reset-password style action endpoint is clearer than inventing a fake resource. And sometimes returning 404 instead of 403 is the right call: if confirming that a private resource exists leaks information, GitHub-style 404s are a reasonable choice. Pick a convention, document it, and make the status code agree with the body.