Old-School Networking / 2026 ・ No.10

Old-School Networking

Part 10: HTTP — reading the submit button's message, line by line

Published

The Wireshark window: ten packets between the PC and port 3000 on 203.0.113.20, with packet 4, POST /api/expenses HTTP/1.1, selected. Hypertext Transfer Protocol is expanded in the details: the lines POST /api/expenses HTTP/1.1, Host, User-Agent, Accept, Content-Type: application/json and Content-Length: 54 each end in \r\n, and after a blank \r\n comes File Data: 54 bytes. Packet 6 is HTTP/1.1 201 Created
The submission we've been sending since part 1, sent over plain HTTP and recorded. Packet 4 is the submission, packet 6 the “accepted” reply (201 Created). The details show each line ending in \r\n, then an empty \r\n, then the 54-byte body (Recorded on 25 October 2026 on a practice network the author built in the cloud and opened in Wireshark 4.2.2. The bar across the top of the window and the headings were added for the illustration.)

The POST /api/expenses we've been sending since part 1. We looked up the name (part 9), connected (part 6) and locked it (part 8); now, at last, we read what's inside.
HTTP is requests and replies made of plain lines of text. Its shape, methods, status codes, caching (ETag and 304), cookies, CORS, compression and the differences between HTTP/1.1, 2 and 3, all measured on a practice server.

1

The layer that reads the submission

AraiNine parts since part 1, and we finally get to what the submission says. What a journey.

FuruiThe address on the envelope, the postal route, the registered-mail lock: those were the lower layers. Today we open the letter.

AraiAnd what's written in it?

FuruiDisappointingly ordinary text. “POST /api/expenses, the body is JSON, 54 bytes.” A human can read it.

Route map of all 12 parts (you are here: part 10)

1 Big picture 2 Physical 3 Data link 4 IP addresses 5 Routers 6 TCP 7 UDP, sockets 8 TLS 9 DNS 10 HTTP 11 Dev networks 12 Troubleshoot You are here Overview Layer 1 Layer 2 Layer 3 Layer 4 Layers 5-6 Layer 7 Practice
  • Shape — first line, headers, a blank line, body. The submission is a 149-byte header plus a 54-byte body (measured)
  • Methods and status — what to do, and what happened. We made the server return ten different codes (measured)
  • Speed-ups — 304 sends a 0-byte body, gzip turns 2 MB into 6 KB, HTTP/2 runs requests together on one connection (measured)
  • Developer pitfalls — the three cookie flags, and who fixes a CORS error

In TCP/IP terms HTTP is layer 7, the topmost agreement in the application layer. The practice network is the same as in part 9: a PC, and across part 6's mischief wire (20 ms each way) the expense server, written in Node.js. It listens for HTTP (port 3000), HTTPS (port 3443, both HTTP/1.1 and HTTP/2) and unencrypted HTTP/2 (port 3080, for observation).

2

The shape of a request and response

To see inside, we sent the submission over plain, unlocked HTTP and recorded it with curl -v and Wireshark (the Wireshark window is the cover picture).

# Request: first line (method, path, version), headers, a blank line, the body
    POST /api/expenses HTTP/1.1
    Host: expenses.example:3000
    User-Agent: curl/8.5.0
    Accept: */*
    Content-Type: application/json
    Content-Length: 54
    
    {"date":"2026-10-25","item":"交通費","amount":1280}
# Response: first line (version, status code), headers, a blank line, the body
    HTTP/1.1 201 Created
    Location: /api/expenses/EXP-2026-0012
    Content-Type: application/json
    Content-Length: 43
    Date: Sun, 25 Oct 2026 00:28:31 GMT
    Connection: keep-alive
    Keep-Alive: timeout=5
    
    {"id":"EXP-2026-0012","status":"submitted"}

Requests and responses alike: first line, headers, blank line, body

Request (PC → server) POST /api/expenses HTTP/1.1 first line Host: expenses.example:3000 Content-Type: application/json Content-Length: 54 headers blank {"date":…,"amount":1280} body 149-byte header + 54-byte body Response (server → PC) HTTP/1.1 201 Created first line Location: /api/expenses/EXP-… Content-Type: application/json Content-Length: 43 headers blank {"id":"EXP-2026-0012",…} body header + 43-byte body Plain text lines split by \r\n; a blank line ends the headers Content-Length gives the body size (part 6's boundary, solved)

The request's first line says what (POST), where (/api/expenses) and which version (HTTP/1.1). The headers that follow are “name: value” lines. Host names the destination (one server answers for many names, so it's required), Content-Type gives the kind of body, and Content-Length its length. A blank line ends the headers, and the 54 bytes of JSON follow.

Part 6 said TCP is a “stream” with no boundaries, so the layer above has to decide where one submission ends. Here is the answer: everything up to the blank line is headers, and the next Content-Length bytes are the body. That rule is how the server cuts up the stream.

The response's first line is version, status code and a short description. 201 Created means “made it”, and Location gives the new submission's address. The closing Connection: keep-alive and Keep-Alive: timeout=5 mean “I'll keep this connection open for five seconds; feel free to use it for your next request.” The connection reuse from parts 6 and 8, which skips the TCP and TLS greetings, is agreed right here.

POINT

Packet 4 in Wireshark is 269 bytes. Ethernet 14, IP 20, TCP 32 (with timestamps), the HTTP header 149 and the body 54 add up to exactly 269. Every layer since part 2 is packed into one packet.

3

Methods: what to do

The first word of the first line is the method. On the same /api/expenses, GET means “show me the list” and POST means “file a new submission”.

MethodMeaningRead-only (safe)Same result if repeated (idempotent)In the expense app
GETfetch✓✓view the list
POSTsend for processing✗✗submit
PUTreplace entirely✗✓(405 on this practice server)
PATCHchange part of it✗✗fix just the amount
DELETEremove✗✓withdraw a submission

The column that matters is the second from the right: idempotent. It means “sending the same request any number of times has the same effect as sending it once.” The HTTP specification (RFC 9110) makes the safe methods such as GET, HEAD and OPTIONS idempotent, along with PUT and DELETE. With an idempotent request, if the reply never comes and you time out, you can safely send it again.

AraiSo POST isn't idempotent… What if the reply is slow and I press the submit button twice?

FuruiTwo ¥1,280 train fares, and two apologies to accounting. So the button greys out once pressed, and the server spots duplicate submissions. You need both.

4

Status codes: what happened

We deliberately sent the practice server all sorts of requests and lined up the first lines of the replies.

# Replies from the same API to different requests (status line and relevant headers)
    GET /api/expenses                  → 200 OK
    POST /api/expenses (JSON)          → 201 Created Location: /api/expenses/EXP-2026-0013
    POST (broken JSON)                 → 400 Bad Request
    POST (Content-Type: text/plain)    → 415 Unsupported Media Type
    POST (amount is a string)          → 422 Unprocessable Entity
    PUT /api/expenses                  → 405 Method Not Allowed Allow: GET, POST
    GET /expenses (old URL)            → 301 Moved Permanently Location: /api/expenses
    GET /api/expenses/EXP-2026-9999    → 404 Not Found
    DELETE (no key)                    → 401 Unauthorized WWW-Authenticate: Bearer
    DELETE (with key)                  → 204 No Content

Five classes by the first digit (boxes: codes this part's measurements returned)

1xx 1xx in progress more to come 2xx 2xx success 200 OK, 201 created, 204 no content 200 201 204 3xx 3xx go elsewhere 301 moved, 304 not modified 301 4xx 4xx your request the sender must fix it 400 401 404 405 415 422 5xx 5xx the server not the sender's fault (none produced here) Boxes: codes measured here. The first digit says who fixes it

The first digit of the three gives the class: 2xx success, 3xx “go elsewhere”, 4xx “your request was wrong”, 5xx “the server failed”. A 4xx is for the sender (the front end) to fix; a 5xx is for the server side. In an outage, that one digit is the first thing to look at.

Within 4xx there are distinctions. 400: the JSON is broken and can't be read. 415: “I only take JSON, and you sent text/plain.” 422: “I could read it, but the amount is a string, so the content is wrong.” 401 means “I don't know who you are (show me a key)”, with WWW-Authenticate naming the kind of key. Knowing who you are and still saying “not for you” is 403.

POINT

Read the number, not the words after it. RFC 9110 renamed 422 to “Unprocessable Content”, yet this part's Node.js returned the old “Unprocessable Entity”, and nothing broke. HTTP/2 drops the words altogether: in chapter 6's recording the line is just HTTP/2 200.

5

Caching: ETag and 304

Most times you open the expense list, the same content comes back. Carrying all of it every time is a waste, so the server sends the list along with a tag for its contents, the ETag.

# 1st: the list comes back with a fingerprint of its contents (ETag)
pc$ curl -D - http://expenses.example:3000/api/expenses
    HTTP/1.1 200 OK
    ETag: "41dc1e7dc5a9"
    Cache-Control: no-cache
    (body: 153 bytes)
# 2nd: ask again, saying “I already have the one with this ETag”
pc$ curl -D - -H 'If-None-Match: "41dc1e7dc5a9"' http://expenses.example:3000/api/expenses
    HTTP/1.1 304 Not Modified
    ETag: "41dc1e7dc5a9"
    (body: 0 bytes)

If nothing changed, the reply is just “304, keep what you have”

PC Server GET /api/expenses 200 OK + ETag "41dc…" (153 B) If-None-Match: "41dc…" 304 Not Modified (0 B) keep a copy Unchanged means no body is sent: the round trip stays, the bytes go
The Wireshark window filtered with http down to four packets: 4 GET /api/expenses, 6 HTTP/1.1 200 OK (422 bytes), 14 GET /api/expenses (193 bytes), 16 HTTP/1.1 304 Not Modified (226 bytes). The details of packet 16 show ETag: 41dc1e7dc5a9 and Cache-Control: no-cache, with no body
Fetching the list twice. The second request (14) said “I already have the one with this tag”, so the reply (16) is 304 Not Modified: the same ETag as before, and no body (Recorded on 25 October 2026 on a practice network the author built in the cloud and opened in Wireshark 4.2.2. The bar across the top of the window and the headings were added for the illustration.)

The second time, If-None-Match says “I already have the one with this tag”, and since nothing had changed the server replied 304 Not Modified with a 0-byte body. The number of round trips is the same, but the bigger the list, the more it saves. Browsers do this automatically without being asked.

Despite its name, Cache-Control: no-cache doesn't mean “don't store this”. It means “you may store it, but check with the server before every use” (RFC 9111). To really forbid storing, use no-store. max-age=600 means “use it for ten minutes without asking”, and during that time even the round trip disappears.

POINT

“I fixed it but the page didn't change” is usually the cache. While developing, tick “Disable cache” on the Network tab of the browser's developer tools and save yourself a lot of head-scratching.

6

Forgetful HTTP and cookies

Once a request is done, HTTP forgets everything (it's stateless). So how does it remember that you're logged in? From here on we tried things over HTTPS (port 3443).

# Ask “who am I?” before logging in
pc$ curl https://expenses.example:3443/api/me
    {"error":"login required"}
# Log in, and the server hands over a cookie
pc$ curl -c jar.txt -X POST … https://expenses.example:3443/login
    HTTP/2 200 
    set-cookie: sid=480df7…; Path=/; HttpOnly; Secure; SameSite=Lax
# Ask again with the cookie attached
pc$ curl -b jar.txt https://expenses.example:3443/api/me
    {"user":"arai"}

Hand out a numbered ticket, then ask to see it every time

PC Server GET /api/me 401: who are you? POST /login Set-Cookie: sid=480df7… GET /api/me Cookie: sid=480df7… {"user":"arai"} server's table sid → arai HTTP forgets each request; cookies make it seem to remember HttpOnly: hidden from JavaScript. Secure: HTTPS only SameSite: not sent when coming from other sites

On login the server hands over a numbered ticket (sid) with Set-Cookie. The PC keeps it and attaches it as a Cookie to every later request to the same server, and the server looks the number up: “ah, Arai.” HTTP stays forgetful; cookies let it act as if it remembers. The “session management” job of OSI layer 5, which we met in part 8, is in practice done right here.

The three flags after the ticket are what developers worry about most. HttpOnly hides it from JavaScript, Secure sends it only over HTTPS, and SameSite=Lax leaves it off most requests that arrive from other sites. The ticket is the key itself, so it should be hard to steal and hard to misuse.

POINT

A Secure cookie is never sent to an http:// page. If you log in during development and are immediately logged out again, suspect this first. Browsers also often treat localhost specially; more on that in part 11.

7

CORS: the browser's guard

Say the page lives at https://app.expenses.example and the API at https://expenses.example:3443. The names differ, so they are different origins (the combination of http/https, name and port). Normally a browser won't let one page's JavaScript read replies from another origin. CORS is the agreement by which the API answers whether that's allowed.

When POSTing JSON, the browser first sends an OPTIONS “preflight” before the real request. We reproduced that preflight with curl.

# Preflight: may a page on https://app.expenses.example POST here?
    OPTIONS /api/expenses
    Origin: https://app.expenses.example
    Access-Control-Request-Method: POST
    Access-Control-Request-Headers: content-type
  →
    HTTP/2 204 
    access-control-allow-origin: https://app.expenses.example
    access-control-allow-methods: GET, POST, DELETE
    access-control-allow-headers: Content-Type, Authorization
    access-control-max-age: 600
# From an unknown Origin (https://other.example), no permission headers come back
  →
    HTTP/2 204 

The browser checks first and sends the real request only if allowed

Browser page app.expenses.example browser's guard preflight first API expenses.example OPTIONS (preflight) Access-Control-Allow-Origin if allowed, the real POST The browser guards fetches to another Origin (name, port, http/https) The browser enforces it; curl and server-to-server calls ignore CORS Fix CORS errors by having the server return the permission headers

An allowed origin gets Access-Control-Allow-Origin and friends; an unknown origin gets nothing. Both replies are 204, so note that the server never says “no”. It's the browser that stops it. Without the permission headers the browser doesn't send the real request and prints a CORS error in the developer tools' Console.

AraiI had a CORS error that wouldn't go away no matter how much I changed the front-end code.

FuruiOf course. The fix belongs in the server's reply. That's also why curl never shows it: curl has no guard.

8

Compression

We exported the expense CSV (2 MB) over a wire throttled to 10 Mbit/s.

# A 2 MB CSV, as is and gzipped (10 Mbit/s, 20 ms each way)
pc$ curl https://expenses.example:3443/api/export.csv
    2,097,152 bytes   1.934 s
pc$ curl --compressed …   (Accept-Encoding: deflate, gzip, br, zstd → Content-Encoding: gzip)
        6,179 bytes   0.142 s

The PC says “I can read these encodings” with Accept-Encoding, and the server compresses and replies with Content-Encoding: gzip. 2,097,152 bytes became 6,179, and 1.934 seconds became 0.142. Browsers do this automatically too.

This CSV, though, is one line repeated, so it shrank absurdly. A 2 MB CSV with varied numbers, dates and amounts, compressed the same way, came to 428,597 bytes, about a fifth. Text (HTML, JSON, CSV) compresses well; JPEGs and video, already compressed, barely shrink at all.

POINT

Compressed replies carry Vary: Accept-Encoding, meaning “this reply depends on Accept-Encoding”. It stops a cache along the way from handing a compressed reply to a client that can't read gzip.

9

HTTP/1.1, 2 and 3

A single page usually takes dozens of requests. We fetched six reports that each take 100 ms, over HTTPS, and compared.

# Six reports that each take 100 ms (HTTPS, 20 ms each way)
    HTTP/1.1, one by one   0.953 s   1 connection(s)
    HTTP/1.1, in parallel  0.246 s   6 connection(s)
    HTTP/2, in parallel    0.245 s   1 connection(s)

Time to fetch six reports (measured)

HTTP/1.1, in turn 1 connection(s) 0.953 s HTTP/1.1, parallel 6 connection(s) 0.246 s HTTP/2, parallel 1 connection(s) 0.245 s 0.0 s 0.5 s 1.0 s HTTP/1.1: one request at a time per connection, so open more HTTP/2 runs many requests at once over a single connection (multiplexing)

HTTP/1.1 handles only one request at a time per connection. One after another took 0.953 s, even reusing the connection. The only way to speed up is more connections: six took 0.246 s. Browsers open around six connections to each server for the same reason, which also means six rounds of TCP and TLS greetings.

HTTP/2 numbers the requests on a single connection (streams) and runs them together (multiplexing): 0.245 s on one connection. The text lines became binary frames, but methods, status codes and headers mean exactly the same.

A Wireshark packet list, every protocol HTTP2. Packets 5, 6 and 7 send three GET /api/report requests as HEADERS[1], HEADERS[3] and HEADERS[5], back to back at 0.041 s. Packet 18 carries HEADERS[1]: 200 OK, HEADERS[3]: 200 OK, DATA[1] and DATA[3] together; packet 20 brings HEADERS[5] and packet 23 DATA[5]
Asking for three reports that each take 100 ms, all at once over HTTP/2. The number in brackets is the stream (request) number: the three run side by side on one connection, and the replies come back interleaved at 0.182 s. To show the contents, this one picture was recorded over unencrypted HTTP/2 (h2c) (Recorded on 25 October 2026 on a practice network the author built in the cloud and opened in Wireshark 4.2.2. The bar across the top of the window and the headings were added for the illustration.)

Over HTTPS, the version is settled in passing during the TLS handshake (ALPN: the Client Hello from part 8 carries a note saying “I speak h2 too”). Browsers use HTTP/2 only over HTTPS; the unencrypted HTTP/2 in the picture is a special setup for observation.

HTTP versions and the layers below

HTTP/1.1 TLS TCP IP 1997 (RFC 2068), text HTTP/2 TLS TCP IP 2015, binary frames HTTP/3 QUIC(TLS 1.3) UDP IP 2022, over UDP HTTP/3 moved onto UDP to escape TCP's head-of-line blocking (part 7) The meaning (methods, status codes, headers) is the same in every version

HTTP/2 still had a weak spot. TCP underneath is a single stream, so when one packet is lost, unrelated streams wait for the resend too (part 7's head-of-line blocking). So HTTP/3 moved onto QUIC, on top of UDP. QUIC keeps order per stream and has TLS 1.3 built in. Browsers learn that a server also speaks HTTP/3 from an Alt-Svc header in a reply or a DNS HTTPS record (part 9), and switch over from then on.

10

Checking your own PC

What to checkBrowser (developer tools)curl
Request and reply, line by lineNetwork tab, pick a row, Headerscurl -v https://example.com/
Just the status codeStatus column on the Network tabcurl -s -o /dev/null -w '%{http_code}\n' URL
Which versionRight-click a column header and add Protocol (h2, h3)curl -sI --http2 URL
Caching“(disk cache)” in the Size column, 304 rowscurl -D - -H 'If-None-Match: "…"' URL
CompressionSize column (bytes carried vs unpacked size)curl --compressed -w '%{size_download}\n' URL
CORS preflightRows of type preflight, errors in the Consolecurl -X OPTIONS -H 'Origin: …' -i URL

Start by opening a site you use every day and watching the Network tab. You'll see how many requests one page makes, which version they use and how much they were compressed.

§ — On one page, and next time

  1. HTTP is lines of text: first line, headers, blank line, body. Content-Length gives the body's length (how the TCP stream gets cut up).
  2. The method says what to do. GET, PUT and DELETE are idempotent and safe to resend; beware double submissions with POST.
  3. Read status codes by the first digit: 4xx is the sender's to fix, 5xx the server's. The words are decoration.
  4. With ETag and 304, an unchanged resource costs a 0-byte body. no-cache means “check every time”.
  5. HTTP forgets. A cookie ticket lets it act as if it remembers. HttpOnly, Secure, SameSite.
  6. The browser enforces CORS; the fix is in the server's reply.
  7. gzip turned 2 MB into 6 KB (a typical CSV shrinks to about a fifth). HTTP/2 runs requests together on one connection; HTTP/3 runs over QUIC (UDP).

AraiThat's the submit button's whole journey! But where on this map is the localhost:3000 on my own PC?

FuruiGood question. Next time: the developer's network. localhost, inside and outside Docker, proxies. The places you use every day and still get lost in.

References

The recordings were made on 25 October 2026 on a practice network the author built with network namespaces on Linux (Ubuntu 24.04, kernel 6.18) in the cloud. The server is a practice program written with the http and http2 modules of Node.js 22.23; the PC side is curl 8.5.0. Every name and address is either reserved for documentation or made up for the practice. Between the PC and the server runs part 6's mischief wire (20 ms each way, plus 10 Mbit/s in the compression chapter only). Every number in the text is a real value read from those recordings and the commands' output. The varied-CSV figure is a 2 MB CSV the author generated, compressed with gzip at the same level (6). The characters are fictional.

Old-School Networking, part 10 — HTTP / 2026.10

← Back to all posts