Part 7 — The application layer
The top of the stack: what the bytes actually mean.
Chapter 28 — HTTP: how the web actually talks
In one sentence
HTTP is a simple text conversation — the client sends a request describing what it wants, the server sends back a status code and the content — and it runs on top of the TCP connection from Part 5.
The problem
You have a reliable byte stream to a server (thanks, TCP). Now you need an agreed way to say "give me the home page" and to reply "here it is" or "that doesn't exist".
The idea, in plain language
HTTP is text you could type by hand. That's not an exaggeration — you'll do exactly that in the Practice section, and it's the fastest way to understand it.
A complete request and response
CLIENT SENDS:
------------------------------------------------
GET /index.html HTTP/1.1
Host: example.com
User-Agent: curl/8.4.0
Accept: */*
<blank line>
------------------------------------------------
^ ^ ^
method path version
SERVER REPLIES:
------------------------------------------------
HTTP/1.1 200 OK
Content-Type: text/html; charset=UTF-8
Content-Length: 1256
Cache-Control: max-age=604800
<blank line>
<!doctype html>
<html>...
------------------------------------------------
^ ^ ^
version code reason
Three parts to each: a first line, some headers (Name: value), a
blank line, then optionally a body. That's the whole protocol.
The methods
| Method | Means | Changes data? | Safe to retry? |
|---|---|---|---|
| GET | Give me this | No | Yes |
| POST | Here's some data, do something with it | Yes | No — may create duplicates |
| PUT | Store this at this exact location | Yes | Yes (same result if repeated) |
| PATCH | Partially update this | Yes | Not necessarily |
| DELETE | Remove this | Yes | Yes (already-gone is still gone) |
| HEAD | Like GET but headers only, no body | No | Yes |
| OPTIONS | What can I do here? | No | Yes |
The status codes
You only need the shape, plus a handful of specific ones:
1xx Information (rare)
2xx SUCCESS 200 OK, 201 Created, 204 No Content
3xx REDIRECT 301 Moved Permanently, 302 Found, 304 Not Modified
4xx YOU messed up 400 Bad Request, 401 Unauthorized, 403 Forbidden,
404 Not Found, 429 Too Many Requests
5xx THE SERVER messed up 500 Internal Error, 502 Bad Gateway,
503 Unavailable, 504 Gateway Timeout
The 4xx/5xx split is the most useful thing here: 4xx means your request was wrong; 5xx means the server broke. That immediately tells you who has to fix it.
Two worth extra attention:
- 502 / 504 almost always mean a proxy or load balancer couldn't reach or didn't get a timely answer from the real server behind it (Chapter 36).
- 304 Not Modified is caching working correctly — the server says "you already have the current version".
Headers that matter
| Header | Why it matters |
|---|---|
Host: |
Required in HTTP/1.1. Lets one IP address serve many websites — the server uses it to decide which site you want. |
Content-Type: |
What the body is (text/html, application/json, image/png) |
Content-Length: |
How many bytes the body is, so the receiver knows when it's done |
Cache-Control: / ETag: |
How long this may be cached; how to check if it changed |
Connection: keep-alive |
Reuse this TCP connection for the next request (huge — see below) |
Cookie: / Set-Cookie: |
How state is maintained across a stateless protocol |
Authorization: |
Credentials |
Accept-Encoding: gzip |
"I can handle compressed responses" |
The single biggest performance idea: connection reuse
HTTP is stateless — each request stands alone. But the TCP connection under it doesn't have to be thrown away.
WITHOUT keep-alive (old HTTP/1.0):
open TCP (1 RTT) -> TLS (1 RTT) -> request -> response -> CLOSE
... repeat all of that for every image, stylesheet, script.
WITH keep-alive (HTTP/1.1 default):
open TCP (1 RTT) -> TLS (1 RTT) -> request -> response
-> request -> response
-> request -> response ...
Given the latency budget from Chapter 4, this is the difference between a page loading in 400 ms and 4 seconds. Every modern client does this by default — and it's why an API client that opens a fresh connection per call is so slow.
Practice (30 min) — speak HTTP by hand
A. Type HTTP yourself. This makes the whole chapter click:
# Connect to a web server manually (unencrypted port 80)
nc example.com 80
Then type exactly this, including the blank line at the end:
GET / HTTP/1.1
Host: example.com
Connection: close
(Press Enter twice.) The server replies with headers and HTML. You just were a web browser.
B. The encrypted version (port 443 needs TLS, so use openssl):
openssl s_client -connect example.com:443 -servername example.com -quiet
# then type the same GET request
C. Use curl properly — -v shows you everything:
curl -v https://example.com 2>&1 | head -40
Lines starting with > are what you sent; < is what came back; * is curl's
own commentary (DNS, TCP, TLS).
D. Explore status codes:
curl -sI https://example.com # HEAD -- headers only
curl -sI https://example.com/nothing-here # 404
curl -sI -L https://google.com # -L follows redirects; watch the 301
curl -s -o /dev/null -w '%{http_code}\n' https://example.com
E. See keep-alive save round trips:
# two requests on ONE connection
curl -s -o /dev/null -w 'req1 %{time_total}\n' \
-o /dev/null -w 'req2 %{time_total}\n' \
https://example.com https://example.com
The second is much faster — no TCP or TLS handshake needed.
F. Watch HTTP on the wire (use plain HTTP so it's readable):
sudo tcpdump -i any -n -A 'tcp port 80 and host example.com'
# in another terminal:
curl -s http://example.com > /dev/null
-A prints the payload as text. You'll see the request and response in plain
sight — a good reminder of why HTTPS exists.
Build it in Go (35 min) — HTTP by hand, over TCP and TLS
nc let you type HTTP. This program does the same, then solves the problem
nc hides from you: where does the response body end? HTTP/1.1 has three
answers (Content-Length, Transfer-Encoding: chunked, or "when the server
closes"), and every HTTP client has to implement all three.
// rawhttp: speak HTTP/1.1 by hand over a TCP (or TLS) connection and decode
// the response framing yourself -- Content-Length vs chunked.
//
// go run ./rawhttp http://neverssl.com/
// go run ./rawhttp https://example.com/
package main
import (
"bufio"
"crypto/tls"
"fmt"
"io"
"net"
"net/url"
"os"
"strconv"
"strings"
"time"
)
func main() {
if len(os.Args) != 2 {
fmt.Println("usage: rawhttp URL")
os.Exit(2)
}
u, err := url.Parse(os.Args[1])
if err != nil {
fmt.Println(err)
os.Exit(1)
}
port := u.Port()
if port == "" {
port = map[string]string{"http": "80", "https": "443"}[u.Scheme]
}
addr := net.JoinHostPort(u.Hostname(), port)
// Layer 4: a plain TCP connection...
conn, err := net.DialTimeout("tcp", addr, 5*time.Second)
if err != nil {
fmt.Println(err)
os.Exit(1)
}
defer conn.Close()
// ...wrapped in TLS for https. Everything after this line is identical.
if u.Scheme == "https" {
tc := tls.Client(conn, &tls.Config{ServerName: u.Hostname(), NextProtos: []string{"http/1.1"}})
if err := tc.Handshake(); err != nil {
fmt.Println("TLS:", err)
os.Exit(1)
}
conn = tc
}
conn.SetDeadline(time.Now().Add(10 * time.Second))
// Layer 7: an HTTP request is just text with CRLF line endings.
path := u.RequestURI()
req := "GET " + path + " HTTP/1.1\r\n" +
"Host: " + u.Host + "\r\n" + // mandatory in HTTP/1.1: one IP, many sites
"User-Agent: rawhttp-lab/1.0\r\n" +
"Accept-Encoding: identity\r\n" + // ask for no compression so we can read it
"Connection: close\r\n" +
"\r\n" // the empty line ends the headers
fmt.Printf(">>> request (%d bytes)\n%s", len(req), req)
io.WriteString(conn, req)
r := bufio.NewReader(conn)
status, _ := r.ReadString('\n')
fmt.Printf("<<< %s", status)
headers := map[string]string{}
for {
line, err := r.ReadString('\n')
if err != nil || line == "\r\n" {
break
}
fmt.Print(" ", line)
k, v, _ := strings.Cut(strings.TrimRight(line, "\r\n"), ":")
headers[strings.ToLower(k)] = strings.TrimSpace(v)
}
// Framing: how do we know where the body ends? (Chapter 39)
var body []byte
switch {
case strings.EqualFold(headers["transfer-encoding"], "chunked"):
body, err = readChunked(r)
fmt.Printf("\nbody framing: chunked -> %d bytes after de-chunking\n", len(body))
case headers["content-length"] != "":
n, _ := strconv.Atoi(headers["content-length"])
body = make([]byte, n)
_, err = io.ReadFull(r, body)
fmt.Printf("\nbody framing: Content-Length: %d\n", n)
default:
body, err = io.ReadAll(r) // only valid because we sent Connection: close
fmt.Printf("\nbody framing: read until the server closed (%d bytes)\n", len(body))
}
if err != nil {
fmt.Println("body error:", err)
}
fmt.Printf("first 120 bytes: %q\n", body[:min(120, len(body))])
}
// readChunked decodes "<hex size>\r\n<data>\r\n ... 0\r\n\r\n".
func readChunked(r *bufio.Reader) ([]byte, error) {
var out []byte
for {
line, err := r.ReadString('\n')
if err != nil {
return out, err
}
sizeStr, _, _ := strings.Cut(strings.TrimSpace(line), ";") // ignore chunk extensions
size, err := strconv.ParseInt(sizeStr, 16, 64)
if err != nil {
return out, fmt.Errorf("bad chunk size %q", sizeStr)
}
if size == 0 {
return out, nil // (trailers would follow; we ignore them)
}
chunk := make([]byte, size)
if _, err := io.ReadFull(r, chunk); err != nil {
return out, err
}
out = append(out, chunk...)
r.Discard(2) // the CRLF after each chunk
}
}
$ go run ./rawhttp http://example.com/
>>> request (112 bytes)
GET / HTTP/1.1
Host: example.com
User-Agent: rawhttp-lab/1.0
Accept-Encoding: identity
Connection: close
<<< HTTP/1.1 200 OK
Content-Type: text/html; charset=utf-8
Transfer-Encoding: chunked
Server: cloudflare
Age: 13523
cf-cache-status: HIT
alt-svc: h3=":443"; ma=86400
body framing: chunked -> 577 bytes after de-chunking
What to notice:
- Going from HTTP to HTTPS is a four-line change.
tls.Client(conn, ...)wraps the TCP connection, and every line after it is unchanged, because a*tls.Connis also anet.Conn. That's the layering of Chapter 3, expressed as an interface. - The response headers tell a story.
cf-cache-status: HITandAge: 13523mean a CDN answered from cache, a response about 3.75 hours old (Chapter 40).alt-svc: h3=":443"advertises HTTP/3 for next time (Chapter 30). - Chunked framing exists because a server that streams a response doesn't
know its length in advance. Each chunk is
<hex length>\r\n<data>\r\n, and a zero-length chunk ends the body.
Exercises:
- Remove
Connection: closeand send two requests on the same connection, reading the first response fully (using its framing) before writing the second. You've just implemented keep-alive, the "single biggest performance idea" above. - Send a
HEADrequest and keep theContent-Lengthbranch. Why does it hang? (HEAD responses carry aContent-Lengthbut no body. Clients must know the method to parse the response.) - Compare with the real thing: the Go plan's raw HTTP/1.1 server (Day 61) builds the server side, and the HTTPS guide's HTTP chapter covers HTTP/2 and HTTP/3 framing.
Common confusions
- "HTTP vs HTTPS?" Same protocol; HTTPS runs it inside TLS encryption (Chapter 29). Port 80 vs 443 by convention.
- "Is HTTP stateless?" The protocol is; each request is independent. Cookies and tokens are how applications simulate state on top.
- "Why do I need the
Host:header?" Because one server IP hosts hundreds of sites. Without it, the server doesn't know which one you want. It's mandatory in HTTP/1.1. - "GET vs POST for an API?" GET for reading (safe, cacheable, repeatable), POST for actions that change things. Putting a state-changing operation in a GET causes real bugs when a browser or crawler prefetches it.
Check yourself
- What are the three parts of an HTTP request?
- What's the difference between a 4xx and a 5xx status code?
- Why is the
Host:header required? - What does keep-alive save, and why does it matter so much?
Further reading
- Book (free online, superb): High Performance Browser Networking — Ilya Grigorik, hpbn.co. Chapters on HTTP/1.1, HTTP/2, and TLS. The best free resource for Part 7.
- Docs: MDN Web Docs — "HTTP" section. The reference for methods, headers, and status codes. Bookmark it.
- Video: "HTTP Explained" — search Practical Networking or Hussein Nasser (whose whole channel on network engineering for developers is excellent).
- Tool: your browser's DevTools → Network tab. You already have the best HTTP inspector ever built, installed.