You've built APIs. You've debugged mysterious 400s at 2am. You know HTTP "well enough" — until you don't. This article is the reference I wish existed when I was stepping up from "it works" to "I understand why it works."
We're going deep on headers, methods, status codes, and the internals of HTTP/1.1 — all grounded in real API scenarios.
Tip: Use the table of contents on the right to jump to any section. This is a long one — bookmark it and come back.
Headers — Deep Dive
Content Negotiation
The Accept family lets clients declare preferences. The server picks the highest-quality match or returns 406 Not Acceptable.
GET /api/users/42 HTTP/1.1
Host: api.example.com
Accept: application/json, text/html;q=0.9, */*;q=0.8
Accept-Encoding: gzip, deflate, br
Accept-Language: en-US,en;q=0.9
The q parameter is a quality factor from 0–1. Accept-Encoding is the one you should always enable on your server — it's free bandwidth savings via transparent compression.
Caching Headers
Two mechanisms: expiration-based (avoid the request entirely) and validation-based (check if the cached copy is still good).
| Header | Direction | Meaning |
|---|---|---|
Cache-Control: max-age=3600 |
Response | Fresh for 3600 seconds |
Cache-Control: no-cache |
Both | Must revalidate — not "don't cache" |
Cache-Control: no-store |
Response | Never cache — use for sensitive data |
Cache-Control: private |
Response | Only client may cache, not CDN |
ETag: "abc123" |
Response | Resource fingerprint for validation |
If-None-Match: "abc123" |
Request | Sends ETag back; get 304 if unchanged |
For APIs, the sweet spot is often Cache-Control: no-cache + ETag. The client always validates, but if nothing changed, you skip the body entirely — just a cheap 304.
CORS Headers
CORS is enforced by browsers, not servers — but the server controls the policy. Get this wrong and your frontend devs will hate you.
Access-Control-Allow-Origin: https://yourapp.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Max-Age: 86400
Access-Control-Allow-Credentials: true
When Allow-Credentials: true, you cannot use Allow-Origin: * — it must be a specific origin. Browsers will block it.
Preflight: For any "non-simple" request (custom headers like Authorization, or non-GET/POST methods), browsers first send an OPTIONS request. Your API must respond correctly or the real request never happens.
Security Headers Worth Adding Today
| Header | What it does |
|---|---|
Strict-Transport-Security |
Forces HTTPS for max-age seconds after first visit |
X-Content-Type-Options: nosniff |
Prevents MIME-sniffing attacks |
X-Frame-Options: DENY |
Blocks clickjacking |
Content-Security-Policy |
Fine-grained resource load control |
Debugging Headers You Should Add to Every API
X-Request-ID: 550e8400-e29b-41d4-a716-446655440000 # trace across your entire stack
X-RateLimit-Limit: 1000
X-RateLimit-Remaining: 847
X-RateLimit-Reset: 1740823200
Retry-After: 30 # return with 429 or 503
Sunset: Sat, 01 Jan 2027 00:00:00 GMT # RFC 8594 deprecation
Methods — Semantics & Edge Cases
POST vs PUT vs PATCH — Finally Clear
This is the source of most REST API design arguments. Here's the authoritative breakdown:
| Method | URL | Idempotent? | Full replace? | Use when |
|---|---|---|---|---|
POST |
Server-determined | ❌ | — | Creating a new resource |
PUT |
Client-specified | ✅ | Yes | Full replacement of a known resource |
PATCH |
Client-specified | Depends | No | Partial update (common in practice) |
PUT gotcha: The body must represent the complete resource. Omitting a field means that field is nulled out. If you only want to update a name, use PATCH — not PUT.
Idempotency Keys for POST
POST is not idempotent — calling it twice creates two records. For critical operations (payments, job submissions), add application-layer idempotency:
POST /api/payments HTTP/1.1
Authorization: Bearer sk_live_...
Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000
Content-Type: application/json
# Server stores the key + result.
# Duplicate key within 24h = return cached response.
# No double charges. Safe to retry on network failure.
Status Codes — Edge Cases & Usage
Quick Reference
- 200 OK — standard success
- 201 Created — add Location header
- 202 Accepted — async processing
- 204 No Content — DELETE / PUT
- 301 Moved Permanently
- 304 Not Modified — cache hit
- 400 Bad Request — explain why
- 401 Unauthorized — who are you?
- 403 Forbidden — not allowed
- 404 Not Found
- 409 Conflict — duplicate / ETag
- 422 Unprocessable — validation
- 429 Too Many Requests
- 500 Internal Server Error
- 502 Bad Gateway — proxy issue
The 401 vs 403 Confusion
401 Unauthorized means "I don't know who you are — send credentials." 403 Forbidden means "I know exactly who you are, and you can't do this."
Returning 403 to an unauthenticated user leaks the fact that the resource exists. For truly private resources, return 404 to unauthenticated users — deny the resource's existence entirely.
202 Accepted — The Underused Gem
# Client submits a long-running report job
POST /api/reports HTTP/1.1
# Server immediately responds:
HTTP/1.1 202 Accepted
Location: /api/jobs/abc-123
Content-Type: application/json
{ "jobId": "abc-123", "status": "queued", "pollUrl": "/api/jobs/abc-123" }
# Client polls:
GET /api/jobs/abc-123
HTTP/1.1 200 OK
{ "status": "completed", "resultUrl": "/api/reports/xyz-789" }
HTTP/1.1 Internals
Persistent Connections & Connection Pools
HTTP/1.1 keeps TCP connections alive by default. The performance implication: always use a connection pool in your service-to-service calls.
# ❌ New TCP connection every request
import requests
response = requests.get("https://api.example.com/users")
# ✅ Reuses connections — much faster under load
with requests.Session() as session:
response = session.get("https://api.example.com/users")
Chunked Transfer Encoding
When the server doesn't know the body size upfront, it uses Transfer-Encoding: chunked.
HTTP/1.1 200 OK
Transfer-Encoding: chunked
7\r\n
Mozilla\r\n
9\r\n
Developer\r\n
0\r\n ← zero-length chunk signals end
\r\n
Head-of-Line Blocking
HTTP/1.1 pipelining sends multiple requests before getting responses — but responses must come back in order. This is head-of-line blocking. Browsers work around this by opening 6 parallel TCP connections per origin.
Debugging Checklist
| Symptom | First thing to check |
|---|---|
Mysterious 404
|
Host header — wrong virtual host routing |
| Auth failing randomly |
Authorization stripped by proxy? |
| CORS errors |
Access-Control-Allow-Headers — missing custom headers? |
| Slow uploads |
Expect: 100-continue — proxy buffering issues? |
502s under load |
Connection pool exhaustion |
Add X-Request-ID to every request/response from day one. When something breaks in production at 3am, having a single ID to grep across all your logs is worth its weight in gold.
Top comments (0)