HTTP in Depth
The protocol behind every web page and API call: request and response structure, methods and their properties, status codes, headers, and how HTTP/1.1, HTTP/2 and HTTP/3 differ.
What HTTP is
HTTP (Hypertext Transfer Protocol) is the application-layer protocol a client uses to ask a server for a resource or to send data to it, and the server uses to answer. It carries web pages, images, API calls, software updates and file downloads. It follows the request-response model: one request, one response, and the client always speaks first. Over the public internet it almost always runs inside TLS, which makes it HTTPS (see HTTPS and TLS in Practice).
The current definition is split over a set of Request for Comments
(RFC) documents from June 2022: RFC 9110 (HTTP
Semantics: methods, status codes, headers), RFC 9111 (caching), RFC
9112 (HTTP/1.1), RFC 9113 (HTTP/2) and RFC 9114 (HTTP/3). They
replace the older RFC 2616 and RFC 7230–7235, which you will still
see quoted in older material. Because the semantics live in one
document, a GET means the same thing in every version.
The anatomy of a message
An HTTP request is the message a client sends: a method, a target, headers and sometimes a body. An HTTP response is the server's answer: a status code, headers and usually a body. In HTTP/1.1 both are plain text with the same four parts:
- Start line: for a request, the method, the target and the version; for a response, the version, the status code and a reason phrase.
-
Headers: one per line,
Name: value. Names are case-insensitive. - An empty line marks the end of the headers. Lines end in CR LF (carriage return, line feed).
-
Body (optional): the content itself. Its length
is given by
Content-Lengthor by chunked transfer encoding.
Here a script on the Kestrel Energy customer portal reports a power cut to the outage API:
POST /v1/outages HTTP/1.1
Host: api.example.com
User-Agent: kestrel-portal/2.4
Accept: application/json
Content-Type: application/json
Content-Length: 50
Authorization: Bearer eyJhbGciOiJSUzI1NiIs…
{"postcode": "2511 CV", "description": "No power"}
HTTP/1.1 201 Created
Content-Type: application/json
Content-Length: 62
Location: /v1/outages/4711
{"id": 4711, "postcode": "2511 CV", "status": "investigating"}
The request line says what (create something in the outages
collection); the headers say how (the body is JSON, the
client wants JSON back, here is the access token); the body carries
the data. The response says it worked (201), where the new resource
lives (Location) and returns it.
Methods
The HTTP method (sometimes called
the verb) is the first word of a request and states what the client
wants done with the target resource. The server decides which
methods a resource supports; an unsupported one gets
405 Method Not Allowed with an Allow
header listing the ones that work.
- GET retrieves a representation of the resource. By far the most common method; every link and address bar visit is a GET.
- HEAD is GET without the body: only the status and headers. Useful to check whether something exists or how big it is.
- POST sends data for the resource to process: submit a form, create an item in a collection, start an action.
- PUT replaces the resource at that URL with the body (or creates it there).
- PATCH (RFC 5789) changes part of a resource.
- DELETE removes the resource.
- OPTIONS asks which methods and features a resource supports. Browsers send it as a CORS preflight (see Browsers and the Same-Origin Policy).
- QUERY is new: published as RFC 10008 in June 2026, it is a read-only request that carries its query in the body, for searches too large or complex to fit in a URL. Expect support in servers, proxies and tools to arrive gradually.
Two more exist for special purposes: CONNECT opens a
tunnel through a proxy, and TRACE echoes the request
back (it is normally disabled).
Safe, idempotent, cacheable
RFC 9110 gives each method properties that clients, proxies and caches rely on. A safe method is one whose semantics are read-only: the client does not ask for any change on the server, so a browser may prefetch it and a crawler may follow it freely. (A server can still log the request; what matters is that the client did not request a change.) An idempotent method has the same intended effect whether it is sent once or ten times, so a client may safely retry it after a timeout. Cacheable means a response may be stored and reused (see HTTP Caching).
| Method | Safe | Idempotent | Cacheable | Request body |
|---|---|---|---|---|
| GET | yes | yes | yes | no defined meaning; avoid |
| HEAD | yes | yes | yes | no defined meaning; avoid |
| OPTIONS | yes | yes | no | rarely |
| QUERY | yes | yes | yes | yes (the query) |
| PUT | no | yes | no | yes |
| DELETE | no | yes | no | no defined meaning; avoid |
| POST | no | no | only with explicit freshness information (rare) | yes |
| PATCH | no | no | no | yes |
These properties are promises the server must keep. If
GET /outages/4711/close closes an outage, a link
preview, a prefetching browser or a search engine crawler can close
it by accident. Changes belong in POST, PUT, PATCH or DELETE.
Status codes
An HTTP status code is the three-digit number at the start of a response that tells the client how the request went. The reason phrase after it ("Not Found") is only for humans; programs look at the number. The first digit gives the class:
-
1xx informational: an interim answer, such as
101 Switching Protocols(used to start a WebSocket) or103 Early Hints. - 2xx success: the request worked.
- 3xx redirection: look elsewhere, or use your cached copy.
-
4xx client error: the request was wrong or not
allowed. Repeating it unchanged usually gives the same answer; the
exceptions are
429 Too Many Requestsand408 Request Timeout, where waiting and retrying is the intended response. - 5xx server error: the request may have been fine, but the server, or something behind it, failed.
For troubleshooting, that first digit already splits the work: 4xx sends you to the request (URL, credentials, body), 5xx sends you to the server side (application, proxy, database).
| Code | Meaning | What to check |
|---|---|---|
| 200 OK | Success; the body holds the result | An API that returns 200 with an error message in the body is badly designed; read the body anyway |
| 201 Created | A new resource was created | Location header points to it |
| 204 No Content | Success, deliberately no body | Common answer to DELETE or PUT; not an error |
| 301 Moved Permanently | The resource has a new URL for good | Browsers cache it hard. Clients may turn POST into GET; use 308 to keep the method |
| 302 Found | Temporarily elsewhere | Same POST-to-GET caveat; 307 is the method-preserving version. A redirect loop shows as "too many redirects" |
| 304 Not Modified | Your cached copy is still valid | Normal and good; see HTTP Caching |
| 307 Temporary Redirect / 308 Permanent Redirect | Like 302 / 301, but the method and body must be kept | The safe choice for redirecting API calls |
| 400 Bad Request | The server cannot parse or accept the request | Malformed JSON, missing parameter, bad header |
| 401 Unauthorized | Not authenticated: no valid credentials were sent |
Missing or expired token, wrong scheme; the
WWW-Authenticate header says what is expected
|
| 403 Forbidden | Authenticated (or not needed), but not allowed | Permissions, roles, IP allow lists, a WAF rule |
| 404 Not Found | Nothing at this URL (or the server hides it) |
Typos, missing /v1, wrong host or environment
|
| 405 Method Not Allowed | The URL exists, but not for this method | The Allow header lists valid methods |
| 409 Conflict | The request conflicts with the current state | Duplicate item, edit of an outdated version |
| 422 Unprocessable Content | Well-formed, but the content is invalid | Validation errors: read the response body |
| 429 Too Many Requests | Rate limit reached (RFC 6585) | Slow down; honor Retry-After if present |
| 500 Internal Server Error | Unhandled error in the application | Application logs, recent deployments |
| 502 Bad Gateway | A proxy got an invalid or no answer from the upstream | Is the application behind the proxy running? |
| 503 Service Unavailable | Overloaded or in maintenance | Capacity, health checks, Retry-After |
| 504 Gateway Timeout | A proxy waited too long for the upstream | Slow queries or calls behind the application; proxy timeouts |
Headers
An HTTP header (formally a header field) is a name-value pair in a request or response that carries metadata about the message: what the body is, who is asking, how to cache it. Clients and servers ignore headers they do not understand, which is how HTTP has grown for thirty years without breaking. The ones you will meet every day:
| Header | Sent in | Purpose |
|---|---|---|
Host |
request | Which site is meant. Required in HTTP/1.1; lets one IP address serve many sites (virtual hosts) |
User-Agent |
request | Which client is calling; useful in logs, easy to fake |
Accept |
request | Which media types the client wants back (see content negotiation) |
Content-Type |
both |
The media type of the body, e.g.
application/json
|
Content-Length |
both | Size of the body in bytes |
Location |
response | Target of a redirect, or the URL of a created resource |
Authorization |
request |
Credentials, e.g. Bearer <token>; never
log it
|
WWW-Authenticate |
response | With a 401: which authentication scheme to use |
Cache-Control |
both | Whether and how long the response may be cached (see HTTP Caching) |
Set-Cookie / Cookie |
response / request | State across requests (see Cookies, Sessions and State) |
You can see the headers of any request with
curl -I (a HEAD request) or curl -v:
curl -I https://www.example.com/outages
HTTP/2 200 content-type: text/html; charset=utf-8 content-length: 5120 cache-control: no-cache
Notice the lowercase names: curl negotiated HTTP/2, where header names are always sent in lowercase. Because names are case-insensitive, code must never compare them with an exact string match.
Example: status codes up close
Python's http.client is a low-level client: it shows
the status line and headers exactly as received and does not follow
redirects, which makes it ideal for seeing what a server really
says. The script starts a small outage API on your own machine with
a few routes that answer differently, then sends one request to each
over a single persistent connection.
import http.client
import threading
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
class Handler(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1" # the default is HTTP/1.0
def reply(self, status, body=b"", headers=()):
self.send_response(status)
for name, value in headers:
self.send_header(name, value)
self.send_header("Content-Length", str(len(body)))
self.end_headers()
if self.command != "HEAD":
self.wfile.write(body)
def do_GET(self):
if self.path == "/outages":
self.reply(200, b'{"outages": []}',
[("Content-Type", "application/json")])
elif self.path == "/outage-list":
self.reply(301, headers=[("Location", "/outages")])
elif self.path == "/admin":
self.reply(401, b"Log in first\n",
[("Content-Type", "text/plain"),
("WWW-Authenticate", 'Bearer realm="outage-api"')])
elif self.path == "/report":
self.reply(500, b"Internal error\n", [("Content-Type", "text/plain")])
else:
self.reply(404, b"Not found\n", [("Content-Type", "text/plain")])
do_HEAD = do_GET
def do_DELETE(self):
self.reply(405, b"Method not allowed\n",
[("Content-Type", "text/plain"), ("Allow", "GET, HEAD")])
def log_message(self, format, *args):
pass
server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
threading.Thread(target=server.serve_forever, daemon=True).start()
conn = http.client.HTTPConnection("127.0.0.1", server.server_address[1])
SHOW = ["Content-Type", "Content-Length", "Location", "WWW-Authenticate", "Allow"]
for method, path in [("GET", "/outages"), ("HEAD", "/outages"),
("GET", "/outage-list"), ("GET", "/admin"),
("DELETE", "/outages"), ("GET", "/tariffs"),
("GET", "/report")]:
conn.request(method, path)
response = conn.getresponse()
body = response.read() # read it all before the next request
print(f"{method} {path}")
print(f" HTTP/1.1 {response.status} {response.reason}")
for name in SHOW:
if response.getheader(name):
print(f" {name}: {response.getheader(name)}")
if body:
print(f" body: {body!r}")
conn.close()
server.shutdown()
server.server_close()
GET /outages
HTTP/1.1 200 OK
Content-Type: application/json
Content-Length: 15
body: b'{"outages": []}'
HEAD /outages
HTTP/1.1 200 OK
Content-Type: application/json
Content-Length: 15
GET /outage-list
HTTP/1.1 301 Moved Permanently
Content-Length: 0
Location: /outages
GET /admin
HTTP/1.1 401 Unauthorized
Content-Type: text/plain
Content-Length: 13
WWW-Authenticate: Bearer realm="outage-api"
body: b'Log in first\n'
DELETE /outages
HTTP/1.1 405 Method Not Allowed
Content-Type: text/plain
Content-Length: 19
Allow: GET, HEAD
body: b'Method not allowed\n'
GET /tariffs
HTTP/1.1 404 Not Found
Content-Type: text/plain
Content-Length: 10
body: b'Not found\n'
GET /report
HTTP/1.1 500 Internal Server Error
Content-Type: text/plain
Content-Length: 15
body: b'Internal error\n'
Things to notice:
-
The HEAD response carries the same headers as GET, including
Content-Length: 15, but no body. -
The 301 has only a
Location. A browser (orurllib.request) would now send a second request to/outages;http.clientleaves that to you. -
The 401 includes
WWW-Authenticateto say which credentials are expected; the 405 includesAllow. -
The server sets
protocol_version = "HTTP/1.1"and always sendsContent-Length, so the connection can be reused for all seven requests. Python'shttp.serveris a teaching tool; its documentation says it is not recommended for production.
HTTP/1.1, HTTP/2 and HTTP/3
All three versions carry the same methods, status codes and headers. What changed is how messages travel, and every change targets the same problem: waiting.
HTTP/1.1
HTTP/1.1 (first published in 1997, now RFC 9112) sends readable text over a TCP connection. Connections are kept open for several requests, but on one connection the responses come back strictly in order: a slow response blocks everything behind it. Browsers work around that by opening up to about six connections per host, which costs extra handshakes. It is still everywhere: between proxies and applications, in embedded devices, and as the fallback when nothing else works.
HTTP/2
HTTP/2 (RFC 9113) keeps HTTP's semantics but sends messages as binary frames, and interleaves many requests and responses as separate streams over a single TCP connection (multiplexing). Headers are compressed (HPACK), which matters because the same cookies and user agent are repeated on every request. Browsers only use HTTP/2 over TLS.
HTTP/2 also introduced server push, letting the
server send resources the browser had not asked for yet. It proved
hard to use well; Chrome disabled it by default in version 106
(2022) and recommends 103 Early Hints instead: an
interim response that tells the browser which resources to start
fetching while the server is still preparing the page.
The remaining weakness lies below HTTP: TCP delivers bytes strictly in order, so one lost packet stalls all streams on the connection until it is retransmitted (TCP head-of-line blocking).
HTTP/3 and QUIC
QUIC (RFC 9000) is a transport protocol that runs over the User Datagram Protocol (UDP) and provides what TCP and TLS provided together: reliable, ordered, encrypted streams. Because it knows about the streams, a lost packet only delays the stream it belongs to. Encryption with TLS 1.3 is built in, so the connection and security setup take a single round trip, and a connection can survive a change of IP address, such as a phone moving from Wi-Fi to mobile data.
HTTP/3 (RFC 9114) is HTTP mapped onto
QUIC. A browser usually first connects with HTTP/2 or HTTP/1.1, and
the server advertises HTTP/3 in an Alt-Svc header (or
in DNS); the next connection then tries QUIC on UDP port 443. As of
October 2026, around 40% of websites support HTTP/3 according to
W3Techs. The share of actual requests is lower: Cloudflare measured
about a fifth of requests over HTTP/3 in 2025, because many clients
and networks still fall back to HTTP/2.