HTTP/1.1 → 2 → 3
What an HTTP message really is, why HTTP/1.1 needed six connections per site, how HTTP/2 multiplexes everything over one, why packet loss still hurt — and how HTTP/3 and QUIC fixed it. With Go and Node servers, clients, pools and timeouts that survive production.
The 30-second version
That's the key to this whole page. A GET /users/7 that returns 200 OK with some JSON is the same request in all three versions. Your handler code doesn't change. What changes is the plumbing underneath — and the plumbing decides how fast a page with 80 files loads on a train with bad signal.
Here are the three versions loading the same four files. Each row is one network connection; the colored blocks are files arriving.
What each version fixed
| Version | Year (standardized) | The problem it fixed | The trick |
|---|---|---|---|
| HTTP/1.0 | 1996 | — (the original popular version) | One request per connection, then hang up |
| HTTP/1.1 | 1997 | Opening a new connection for every file is slow | Keep the connection open and reuse it (§04) |
| HTTP/2 | 2015 | Only one request at a time per connection | Many requests at once on one connection, cut into labelled pieces (§05) |
| HTTP/3 | 2022 | One lost packet stalls every request on an HTTP/2 connection | A new transport, QUIC, where each request recovers from loss on its own (§07) |
The map of this page
- §02–§03: what an HTTP message actually looks like, and a tiny server and client in Go and Node — with the timeouts almost everyone forgets.
- §04–§07: the story of 1.1 → 2 → 3, each with an animation you can poke at.
- §08: caching and retries — the parts of HTTP that save you the most traffic.
- §09–§11: using HTTP/2 and HTTP/3 from Go and Node, then the production knobs: connection pools, timeouts, proxies.
- §12–§14: pitfalls, a quiz, and a cheat sheet.
What an HTTP message looks like
Name: value header lines, one empty line, then the optional body. That's all.Where the message travels
Before any HTTP is sent, the client opens a TCP connection to the server: think of it as a phone line that carries bytes reliably and in order between two programs. The server is found by its IP address and a port — 80 for plain HTTP, 443 for HTTPS. HTTP/1.1 then simply writes its text message into that line, and reads the reply back out. (How TCP keeps the line reliable is another page.)
That split is the idea this whole page rests on. HTTP rides on a transport — TCP, or QUIC in HTTP/3. The transport moves bytes; HTTP gives them meaning. Watch one request travel down the layers, across the network as packets, and back up — then flip to HTTP/3 and see that only the plumbing changes:
GET /hello. The HTTP/1.1 layer turns that into bytes: GET /hello HTTP/1.1␍␊Host: example.com␍␊␍␊.A real exchange, captured with curl
curl is a command-line HTTP client. With -v ("verbose") it prints everything it sends (lines starting >) and receives (<). This is real output from the tiny Go server that §03 builds (the ← notes are added):
$ curl -v "http://localhost:8080/hello?name=ada"
* Trying 127.0.0.1:8080...
* Connected to localhost (127.0.0.1) port 8080
> GET /hello?name=ada HTTP/1.1 ← request line: method, path, version
> Host: localhost:8080 ← headers, one per line
> User-Agent: curl/8.5.0
> Accept: */*
> ← empty line: "headers are done"
< HTTP/1.1 200 OK ← status line: version, code, reason
< Content-Type: text/plain; charset=utf-8
< Date: Fri, 30 Oct 2026 10:32:14 GMT
< Content-Length: 26 ← the body is exactly 26 bytes
<
hi ada, you sent HTTP/1.1 ← the body
* Connection #0 to host localhost left intactThe parts, in order:
- Request line —
GET /hello?name=ada HTTP/1.1. The method (what to do), the path (which thing; the?name=adapart is the query string), and the protocol version. - Headers — metadata.
Hostsays which website you want (one server can host many);Acceptsays what formats you'd like back. - An empty line. Strictly, every line ends with two invisible characters, carriage return + line feed (written
\r\n, or "CRLF"). An empty line is just\r\non its own. - Status line —
HTTP/1.1 200 OK. The status code is the number; "OK" is a human-readable reason that programs ignore. - Content-Length — tells the reader exactly where the body ends, so it knows when the message is complete.
Build one yourself
Change the method and the cookie, and watch both messages change. Click any line to see what it does. Turn on "show invisible characters" to see the \r\n at every line end.
GET /api/users/7 HTTP/1.1Host: api.example.comAccept: application/json(empty line)
HTTP/1.1 200 OKCache-Control: max-age=60Content-Type: application/jsonDate: Fri, 30 Oct 2026 10:32:14 GMTContent-Length: 21(empty line){"id":7,"name":"Ada"}
GET (what to do), path /api/users/7 (which thing), and the protocol version. Exactly one space between each part.Typing HTTP by hand
Because HTTP/1.1 is just text, you can talk to a server with nc, which opens a TCP connection and sends exactly what you give it:
$ printf 'GET /hello?name=nc HTTP/1.1\r\nHost: localhost\r\n\r\n' | nc localhost 8080
HTTP/1.1 200 OK
Content-Type: text/plain; charset=utf-8
Date: Fri, 30 Oct 2026 10:32:14 GMT
Content-Length: 25
hi nc, you sent HTTP/1.1printf prints its argument and turns each \r\n into the real two invisible characters. The | ("pipe") feeds that output into nc. Note the two \r\n at the end: one finishes the Host line, the second is the empty line that says "my headers are done". Leave it out and the server waits forever for more headers.
Methods: what you want to do
| Method | Means | Has a body? | Safe to retry blindly? |
|---|---|---|---|
GET | Read this | No | Yes |
HEAD | Like GET, but only send me the headers | No | Yes |
POST | Create something / submit this | Yes | No — might create it twice |
PUT | Replace this with what I'm sending | Yes | Yes (same result twice) |
PATCH | Change part of this | Yes | Depends on the change |
DELETE | Remove this | Usually no | Yes (it's gone either way) |
The last column is about being idempotent: doing it twice has the same effect as doing it once. It matters a lot when a request times out and you're not sure whether it arrived — we'll come back to it in §08.
Status codes: how it went
The first digit is the class; you can usually stop reading there.
| Class | Meaning | Ones you'll see every week |
|---|---|---|
| 2xx | It worked | 200 OK, 201 Created, 204 No Content |
| 3xx | Look elsewhere | 301/308 moved permanently, 302/307 moved temporarily, 304 Not Modified (§08) |
| 4xx | Your request was wrong | 400 Bad Request, 401 not logged in, 403 not allowed, 404 Not Found, 429 Too Many Requests |
| 5xx | The server failed | 500 crashed, 502/504 a proxy in front of the app couldn't get an answer from it, 503 overloaded |
HTTP forgets you between requests
HTTP is stateless: every request stands alone. The server doesn't remember that the request a second ago came from you. So how does a site keep you logged in? With a cookie:
# 1. You log in. The response asks the browser to store a cookie:
< HTTP/1.1 200 OK
< Set-Cookie: session=abc123; HttpOnly; Secure; SameSite=Lax
# 2. From now on, the browser attaches it to every request to that site:
> GET /cart HTTP/1.1
> Host: shop.example
> Cookie: session=abc123The server looks up abc123 in its own storage to find out who you are. The flags matter: HttpOnly hides the cookie from JavaScript on the page (so injected scripts can't steal it), Secure only sends it over HTTPS, and SameSite=Lax stops other sites from making your browser send it in most cross-site requests — e.g. a hidden form on evil.example that posts to your bank, hoping your login cookie rides along.
Your first server and client — with timeouts
fetch waits five minutes.A server
Both servers below answer GET /hello?name=ada with hi ada, you sent HTTP/1.1 — the server from the curl capture in §02.
package main
import (
"fmt"
"log"
"net/http"
"time"
)
func main() {
mux := http.NewServeMux()
mux.HandleFunc("GET /hello", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
fmt.Fprintf(w, "hi %s, you sent %s\n", r.URL.Query().Get("name"), r.Proto)
})
srv := &http.Server{
Addr: ":8080",
Handler: mux,
ReadHeaderTimeout: 5 * time.Second, // ← drop clients that send headers too slowly (§11)
}
log.Fatal(srv.ListenAndServe())
}A client
package main
import (
"fmt"
"io"
"log"
"net/http"
"time"
)
// One client for the whole program — it reuses connections (§10).
var client = &http.Client{Timeout: 5 * time.Second} // ← the whole request must finish in 5 s
func main() {
resp, err := client.Get("http://localhost:8080/hello?name=ada")
if err != nil {
log.Fatal(err) // network failure or timeout — NOT a 404 or 500
}
defer resp.Body.Close() // ← always close the body (§12 explains why)
if resp.StatusCode != http.StatusOK {
log.Fatalf("unexpected status: %s", resp.Status)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
log.Fatal(err)
}
fmt.Print(string(body))
}Why the timeout isn't optional
Picture a server that accepts your connection and then never answers — it's overloaded, or stuck, or a network device in between silently dropped the connection. Without a timeout:
- Go:
http.DefaultClient(whathttp.Getuses) hasTimeout: 0, which means no limit. The goroutine making the call waits forever. In a server, every incoming request that calls the stuck service adds one more stuck goroutine, until you run out of memory. - Node:
fetchhas internal default limits of 300 seconds to receive the headers, and 300 seconds of silence while reading the body (in Node 18+) — so a body that trickles in slowly can take far longer than that. Five minutes is "forever" to a user, and long enough for thousands of requests to pile up. - Browsers:
fetchhas no timeout you can rely on.
With the 5-second limits above, you get a clear error instead. Real output when the server takes too long:
# Go
Get "http://127.0.0.1:35577": context deadline exceeded (Client.Timeout exceeded while awaiting headers)
# Node (err.name | err.message)
TimeoutError | The operation was aborted due to timeoutHTTP/1.1: keep-alive, and the six-connection hack
What a new connection costs
Before the first byte of HTTP can be sent, two handshakes have to happen:
- TCP: client and server exchange three small messages to agree the connection is open. That costs one RTT (round-trip time: how long a message takes to get there and the reply to come back). TCP vs UDP shows it step by step.
- TLS (for HTTPS): they agree on encryption keys and the server proves its identity. With TLS 1.3 that's one more RTT; the older TLS 1.2 needs two. TLS & HTTPS covers it.
Let's put numbers on it. Say your user is far from your server and 1 RTT = 100 ms (a typical cross-continent figure; it's about 1 ms inside a data center). Fetching one file over a fresh HTTPS connection costs 100 ms (TCP) + 100 ms (TLS) + 100 ms (request goes out, response comes back) = 300 ms, before counting the time to actually transfer the bytes.
HTTP/1.0: hang up after every file
In HTTP/1.0, the server closed the connection after each response. A page with 10 files fetched one after another meant 10 × 300 ms = 3 seconds — and two thirds of that is handshakes. HTTP/1.1 made the connection stay open by default (keep-alive), so only the first file pays for the handshakes: 200 ms + 10 × 100 ms = 1.2 s.
A connection stays open until one side decides to close it: either side can send the header Connection: close, and servers close connections that sit idle too long (Node's default is 5 seconds, Go's is unlimited unless you set IdleTimeout).
Knowing where a response ends
Keep-alive creates a new problem. In HTTP/1.0, "the server hung up" meant "the response is complete". With the connection staying open, the client needs another way to know where one response ends and the next begins. Two options:
Content-Length: 26— "the body is exactly 26 bytes". Easy, but the server must know the size before sending anything.- Chunked transfer encoding — for bodies whose size isn't known yet (a page being generated, a live log). Each piece is sent with its size first, and a zero-size piece means "done". Real bytes from a Node server that wrote
part1and, 100 ms later,part2(theDateandConnectionheaders are trimmed; the blank lines are real — every chunk's data is followed by\r\n):
$ printf 'GET / HTTP/1.1\r\nHost: x\r\n\r\n' | nc localhost 8085
HTTP/1.1 200 OK
Transfer-Encoding: chunked
6 ← next piece is 6 bytes (the size is written in hex)
part1 ← "part1" + a newline = 6 bytes
6
part2
0 ← zero-size piece: the body is finishedSizes are in hexadecimal (base 16), so a 25-byte piece would start with 19 (1×16 + 9 = 25). You never write this format yourself. Go and Node switch to chunked automatically when the body is streamed in several writes, or is too big to buffer. For a small body written in one go, both compute Content-Length for you — that's why §02's capture has one.
One server, many websites: the Host header
HTTP/1.0 requests didn't say which website they wanted — only the path. So one IP address could only serve one site. HTTP/1.1 made the Host header required: Host: shop.example lets a single server at a single address host thousands of sites ("virtual hosting"). It's why curl always sends one.
The limit: one request at a time
Keep-alive saved the handshakes, but on each connection the client still has to wait for the whole response before sending the next request. If style.css takes 2 seconds to generate, logo.png waits behind it on that connection, even though it's ready instantly. That's head-of-line blocking: the item at the front of the line holds up everything behind it.
Pipelining tried to fix it: send all the requests at once, without waiting. But HTTP/1.1 has no way to label which response belongs to which request — responses must come back in the same order the requests went out. So the slow style.css still blocks logo.png behind it, just on the server side now. Add buggy proxies that mixed up pipelined responses, and every major browser left pipelining switched off.
The workaround: six connections
Browsers gave up on squeezing more out of one connection and opened several connections per hostname — 6 in all major browsers. Six requests run truly in parallel; the seventh waits for one of them to finish. Watch 12 files load over HTTP/1.1. Each row is one file; the label says which of the 6 connections carries it:
Notice the two waves: files 7–12 sit in striped grey ("queued") until a connection frees up, and then each still pays a full round trip before its first byte arrives — its time to first byte can never be less than one RTT.
Web developers piled hacks on top:
- Domain sharding — serve images from
img1.example.com,img2.example.com… to get 6 connections per name. - Bundling and sprites — glue all JavaScript into one file, all icons into one image, so there are fewer requests to queue.
- Inlining — paste small CSS or images straight into the HTML.
HTTP/2: many streams on one connection
Same meaning, new wire format
HTTP/2 is a binary protocol: instead of text lines, every frame starts with a 9-byte header in fixed positions — how long the frame is, what type it is, some flags, and the stream ID. A stream is one request/response pair; every frame belongs to exactly one stream.
The meaning is unchanged. The request line is split into special "pseudo-headers" that start with a colon, and header names are lowercase. curl -v --http2-prior-knowledge shows them translated back into something readable:
$ curl -v --http2-prior-knowledge http://localhost:8082/
> GET / HTTP/2 ← curl's display; on the wire it's :method GET, :path /
> Host: localhost:8082 ← sent as the pseudo-header :authority
> User-Agent: curl/8.5.0
< HTTP/2 200 ← really the pseudo-header :status 200; no "OK" text any more
< content-type: text/plain; charset=utf-8
< content-length: 19(--http2-prior-knowledge means "I know this server speaks HTTP/2 without encryption, just start" — §09 explains when that's allowed.)
Watch the frames interleave
Three requests, one connection. Step through and watch frames from different streams share the wire, then get sorted back into separate files. The code panel is the frame header every frame starts with:
1// Every HTTP/2 frame starts with this 9-byte header2// (like FrameHeader in golang.org/x/net/http2)3type FrameHeader struct {4 Length uint32 // 24 bits: payload size5 Type uint8 // DATA, HEADERS, SETTINGS…6 Flags uint8 // e.g. END_STREAM7 StreamID uint32 // which request it's part of8}9// …followed by Length bytes of payload
Two details from the stepper are worth remembering:
- Client-started streams get odd numbers: 1, 3, 5… Node's
http2module shows it plainly: three requests on one connection printstream 1,stream 3,stream 5. END_STREAMis a flag on the last frame of a stream. That's how the receiver knows a response is complete — noContent-Lengthor chunking needed.
The race: HTTP/1.1 vs HTTP/2
Same 12 files, same network as the §04 animation. HTTP/2 sends all 12 requests the moment its one connection is ready, and the responses share the bandwidth (the overlapping bars):
HTTP/2 wins here because it never has a second wave: no request sits queued, and no request pays an extra round trip waiting for a free connection. (The model is simplified, and all numbers are approximate — but the shape matches what you'd see in your browser's network tab.)
HPACK: stop sending the same headers 80 times
Every request carries headers, and they're mostly identical: the same user-agent, the same accept, often a cookie of several hundred bytes. A page with 80 requests and 600 bytes of headers each sends 48 KB of almost-identical text.
HPACK fixes this. Both sides keep a table of headers already seen on this connection. The first time, cookie: session=… is sent in full and added to the table; after that it's sent as "entry 62" — a byte or two instead of hundreds. Common headers like :method: GET have permanent entries from the start.
Flow control: don't drown the reader
If one stream is a 2 GB download and the app reading it is slow, the sender shouldn't fill the receiver's memory with it. Flow control gives each stream (and the connection as a whole) a "window": the number of body bytes the sender may send before waiting. The default starting window is 65,535 bytes. As the receiving app reads data, its side sends WINDOW_UPDATE frames saying "you may send N more". Your code never sees this — Go and Node do it for you — but it's why a stream whose reader stops reading eventually pauses, instead of buffering forever.
Server push: a feature that didn't work out
HTTP/2 let a server send files the browser hadn't asked for yet ("you asked for index.html; you'll want style.css too, here it is"). In practice server push often pushed files the browser already had cached, wasting bandwidth, and it was hard to get right. Chrome removed it in 2022 and Firefox in 2024. Don't build on it. The replacement is a hint the browser can act on: a 103 Early Hints response or a Link: </style.css>; rel=preload header. Both say "you'll need style.css" and let the browser decide whether to fetch it — so it won't refetch a file it already has cached.
How the two sides agree to speak HTTP/2
Browsers only use HTTP/2 over HTTPS. During the TLS handshake, the client lists what it speaks (h2, http/1.1) and the server picks one. That's called ALPN, and it costs no extra round trip. If the server doesn't pick h2, everyone falls back to HTTP/1.1 — which is why turning on HTTP/2 rarely breaks anything.
Where HTTP/2 still hurts: TCP head-of-line blocking
TCP only knows about one stream of bytes
To the operating system, an HTTP/2 connection is a single TCP connection: one long, ordered stream of bytes. TCP has no idea that bytes 1000–2000 belong to style.css and bytes 2000–3000 to logo.png. Its one promise is: the application receives every byte, in the original order.
So when a packet goes missing — packet loss happens all the time on Wi-Fi and mobile networks — TCP must wait for the resend before it can hand over anything that came after it. The later packets have already arrived; they just sit in a buffer inside the operating system. Noticing the loss and getting the resend takes at least one round trip, often more.
Toggle between the two transports and see which packets the application is allowed to read. Each packet carries frames from one stream (the colors):
On TCP, packet 3 belongs to stream C — but streams A and B, whose data has fully arrived, wait too. That's head-of-line blocking at the transport layer, and no HTTP/2 feature can work around it, because the waiting happens below HTTP.
Now race them with packet loss
Turn on packet loss. One packet of file 2 is lost shortly after the downloads start, and the red striped bars show who has to wait for the resend:
With loss on:
- HTTP/1.1 has 6 separate TCP connections. Only the connection carrying file 2 stalls; the other five keep going.
- HTTP/2 has one TCP connection. Every stream that hasn't finished stalls for the whole round trip.
Fixing this means replacing TCP — but you can't just change TCP. It's built into every operating system and every network device between you and the server, many of which drop traffic they don't recognize. The next section shows the clever way around that.
HTTP/3 and QUIC
Why build on UDP?
The obvious fix for §06 would be "change TCP so it understands streams". But TCP lives inside every operating system, and between you and any server sit dozens of routers, firewalls and NAT boxes that inspect TCP traffic and often drop anything that looks unfamiliar. A new TCP would take decades to roll out. (People call this "ossification": the network has set like bone around the protocols it already knows.)
UDP, on the other hand, is a nearly empty envelope: "deliver this packet to that port, maybe". Networks already let it through, because DNS, video calls and games use it. So QUIC is built on top of UDP, and all the hard parts — reliable delivery, ordering per stream, congestion control, encryption — live inside the QUIC library that ships with your browser or server. That also means QUIC can improve with an app update instead of an OS update. And because QUIC encrypts almost all of its own headers, middleboxes (those routers, firewalls and NAT boxes) can't peek at them and start depending on them — so it should stay changeable.
Loss only stalls its own stream
Each QUIC stream is delivered in order on its own. If a packet carrying stream C's data is lost, streams A and B keep handing data to the application; only C waits for the resend. That's the "QUIC" setting of the packets widget in §06. HTTP/3 even had to replace HPACK with QPACK, because HPACK assumed headers arrive in strict order across all streams — exactly the assumption QUIC removes.
One round trip to start, sometimes zero
With HTTP/2, the client needs a TCP handshake, then a TLS handshake, before it can send a request. QUIC does the transport and the TLS 1.3 handshake together in the same packets. And when reconnecting to a server it has talked to recently, the client can even send its request in the very first packet — 0-RTT.
With our 100 ms round trip: the first response arrives after about 300 ms on HTTP/2 over TLS 1.3, 200 ms on HTTP/3, and 100 ms with 0-RTT.
POST /transfer amount=500 inside its very first packet: 0-RTT early data.Connection migration: Wi-Fi → cellular without a hiccup
A TCP connection is identified by four numbers: your IP address and port, and the server's IP address and port. Walk out of the house, your phone switches from Wi-Fi to cellular, your IP address changes — and every TCP connection is dead. The app has to reconnect: new handshakes, and requests in flight fail or restart.
QUIC puts a random connection ID in every packet. When packets with a known connection ID start arriving from a new address, the server checks that the new path really works and carries on. That's connection migration: the video keeps streaming as you walk out the door. (In practice clients support it better than servers and load balancers do so far — a load balancer has to route by connection ID instead of by address for it to work.)
How the browser finds out a server speaks HTTP/3
A browser can't know in advance that a server speaks QUIC on UDP. The usual flow:
- The first visit uses HTTP/2 (or 1.1) over TCP, as normal.
- The response includes an Alt-Svc header, for example
alt-svc: h3=":443"; ma=86400— "I also speak HTTP/3 on UDP port 443; remember this for 86,400 seconds (a day)". - Later connections try HTTP/3. If UDP is blocked (some corporate networks block it), the browser quietly falls back to TCP.
Newer setups also publish the same hint in a DNS record of type HTTPS, so even the first connection can go straight to HTTP/3.
The full race
All three versions, same 12 files. Try it with and without loss:
HTTP/3 starts a round trip earlier (the shorter setup), and with loss on, only file 2 waits.
Adoption: honest numbers
- Browsers: all major browsers support HTTP/3.
- Servers: most big CDNs and large sites (Google, Meta, Cloudflare's customers) have it on. Roughly a third of websites advertise HTTP/3 support (survey figures from 2025 — check current numbers, they're moving). Popular self-hosted servers have added support more slowly.
- Not everywhere it helps: inside a data center, round trips are ~1 ms and loss is rare, so TCP + HTTP/2 or even HTTP/1.1 between your own services is perfectly fine.
- It costs CPU: QUIC runs in a normal program instead of inside the operating system itself (the kernel) and can't yet use all the network-card tricks TCP has, so serving the same traffic takes noticeably more CPU.
Caching, conditional requests and safe retries
A cache is a store of answers you already have. Browsers, CDNs and proxies all keep HTTP caches. The server decides what they may do with each response, using headers.
Cache-Control: how long a copy stays fresh
Cache-Control is a response header with a few instructions you'll see constantly:
Cache-Control: | Means | Typical use |
|---|---|---|
max-age=31536000, immutable | Reuse for a year without asking; it will never change | app.3f9a1c.js — the hash in the name changes when the content does |
max-age=60 | Reuse for 60 s, then ask again | A product listing that can be a minute stale |
no-cache | You may store it, but check with me every time before using it | HTML pages, API responses that must be current |
no-store | Don't store it at all | Bank statements, anything private and sensitive |
private | Only the user's own browser may store it, not shared caches like CDNs | Pages personalized for one user |
ETag: "has it changed since this version?"
When a stored copy needs checking, re-downloading it every time would waste the cache. Instead the server labels each version with an ETag (often a hash of the content). The client sends the label back in a conditional request: If-None-Match: "v42" — "send it only if your current version is not v42". If nothing changed, the server answers 304 Not Modified, with headers but no body.
1# First visit2GET /users/7 HTTP/1.134HTTP/1.1 200 OK5ETag: "v42"6Cache-Control: no-cache7Content-Length: 2189{"id":7,"name":"Ada"}1011# Later: is my copy still current?12GET /users/7 HTTP/1.113If-None-Match: "v42"1415HTTP/1.1 304 Not Modified16ETag: "v42"
/users/7, so its cache is empty. It sends a normal GET.Here's the server side, by hand so you can see every step:
var (
body = []byte(`{"id":7,"name":"Ada"}`)
etag = fmt.Sprintf(`"%x"`, sha256.Sum256(body)) // version label = hash of the content
)
func user(w http.ResponseWriter, r *http.Request) {
w.Header().Set("ETag", etag)
w.Header().Set("Cache-Control", "no-cache") // "check with me every time"
if r.Header.Get("If-None-Match") == etag {
w.WriteHeader(http.StatusNotModified) // ← 304, no body
return
}
w.Header().Set("Content-Type", "application/json")
w.Write(body)
}In real code you rarely hand-roll this: Go's http.ServeContent and http.FileServer, Express's res.send, and every static-file server handle ETag, Last-Modified and If-None-Match for you — including the details skipped above, like a client sending several ETags at once. Last-Modified / If-Modified-Since is the older, date-based version of the same idea.
Retries: which requests can you safely send twice?
Networks fail halfway all the time. Your request timed out — did the server receive it and act on it, or not? You can't know. Whether it's safe to just send it again depends on the method:
- Safe methods (
GET,HEAD,OPTIONS) only read. Retrying is always fine. - idempotent methods (
PUT,DELETE, plus the safe ones) give the same end result if repeated. "Set the name to Ada" twice is still "Ada". Retrying is fine. POSTand mostPATCHare neither. Retrying "charge the card €50" may charge it twice.
To make a POST safely retryable, the client sends a unique ID with it, usually an Idempotency-Key: 8c1f… header — an idempotency key. The server remembers which keys it has already processed and returns the stored result for a repeat instead of doing the work again. Payment APIs do exactly this.
When you retry yourself, wait a little longer each time and add randomness, and cap the total — retries with backoff and jitter shows the code, and a retry budget stops retries from multiplying an outage.
HTTP/2 and HTTP/3 in Go and Node
Go: HTTP/2 is automatic over TLS
Serve with TLS and Go negotiates HTTP/2 with every client that offers it (that's the ALPN step from §05). Your handlers don't change; r.Proto tells you what was used.
srv := &http.Server{
Addr: ":8443",
Handler: mux,
ReadHeaderTimeout: 5 * time.Second,
}
// cert.pem / key.pem: your TLS certificate and private key (see the TLS topic)
log.Fatal(srv.ListenAndServeTLS("cert.pem", "key.pem")) // ← h2 and http/1.1, chosen per clientThe client side is automatic too: http.Client uses HTTP/2 with any HTTPS server that supports it — unless you customize the transport.
A custom Transport silently turns HTTP/2 off
The moment you give a http.Transport your own TLSClientConfig (for example to trust a private certificate) or your own dial function, Go stops attempting HTTP/2 unless you also set ForceAttemptHTTP2: true. Nothing errors; you just get HTTP/1.1. Real output, same server, only that one field changed:
ForceAttemptHTTP2 = false → HTTP/1.1
ForceAttemptHTTP2 = true → HTTP/2.0tr := &http.Transport{
TLSClientConfig: &tls.Config{RootCAs: myCAs},
ForceAttemptHTTP2: true, // ← without this line: HTTP/1.1 only
}
client := &http.Client{Transport: tr, Timeout: 10 * time.Second}A simpler habit: start from a copy of the default, which already has it on — tr := http.DefaultTransport.(*http.Transport).Clone(), then change what you need. (.(*http.Transport) is a type assertion: DefaultTransport is declared as an interface, and this says "I know the value inside is a *http.Transport, give me that".)
Go: HTTP/2 without TLS (h2c)
Browsers never speak HTTP/2 without encryption. But between your own services, or in tests, plain-text HTTP/2 — h2c — can make sense. Since Go 1.24, the standard library supports it through http.Protocols:
var p http.Protocols
p.SetHTTP1(true)
p.SetUnencryptedHTTP2(true) // ← accept h2c as well as HTTP/1.1
srv := &http.Server{Addr: ":8082", Handler: mux, Protocols: &p, ReadHeaderTimeout: 5 * time.Second}That's the server curl --http2-prior-knowledge talked to in §05. A Go client opts in the same way, with Protocols on its http.Transport. (Before Go 1.24 you'd use the golang.org/x/net/http2/h2c package.)
Node: the http2 module
Node keeps HTTP/2 in its own module, node:http2. Setting allowHTTP1: true lets old clients use HTTP/1.1 on the same port, and the "request" event gives you the familiar (req, res) handler:
import http2 from "node:http2";
import { readFileSync } from "node:fs";
const server = http2.createSecureServer({
key: readFileSync("key.pem"),
cert: readFileSync("cert.pem"),
allowHTTP1: true, // ← HTTP/1.1 clients still work
});
server.on("request", (req, res) => {
res.writeHead(200, { "content-type": "text/plain" });
res.end(`you spoke HTTP/${req.httpVersion}\n`); // "2.0" or "1.1"
});
server.listen(8443);curl -k https://localhost:8443/ prints you spoke HTTP/2.0; with --http1.1 it prints you spoke HTTP/1.1. (-k skips certificate checking — fine for a self-signed test certificate, never for real traffic.) For the stream-level API, listen to the "stream" event instead; http2.connect() is the matching low-level client.
On the client side, Node's built-in fetch uses HTTP/1.1 by default (as of Node 22). The undici package it's built on can enable HTTP/2 with an Agent option (allowH2: true); check the version you run, because this has been changing.
HTTP/3 support today
| Server | Client | |
|---|---|---|
| Browsers | — | Yes, all major ones |
| Go | Not in the standard library (Go 1.24). The widely used library is quic-go (quic-go/http3). The Go team has been building experimental QUIC packages under golang.org/x/net — check their status for your Go version. | Same: quic-go provides an http.RoundTripper (the interface a Transport implements), so you plug it into a normal http.Client |
| Node | No stable support in Node 22. QUIC has been in development for years and has appeared as an experimental, flag-gated module in newer releases — check the docs for your version before relying on it. | Same |
| curl | — | curl --http3, if your curl was built with HTTP/3 support |
Connection pools and the timeout taxonomy
How Go's client reuses connections
A Go http.Client hands the real work to its http.Transport. When a request finishes and its body has been read and closed, the Transport puts the connection into an idle pool, keyed by host. The next request to that host takes an idle connection instead of paying for new handshakes. How many connections it keeps is set by a few fields:
http.Transport field | Default (DefaultTransport) | Meaning |
|---|---|---|
MaxIdleConns | 100 | Idle connections kept, all hosts together |
MaxIdleConnsPerHost | 2 (when left at 0) | Idle connections kept per host |
MaxConnsPerHost | 0 = unlimited | Hard cap on connections per host, busy or idle |
IdleConnTimeout | 90 s | Close a connection that sat idle this long |
The trap: MaxIdleConnsPerHost = 2
Most backend services call one host — their database API, a payment provider, another internal service — with dozens of requests at once. Say 10 requests run concurrently. Go opens 10 connections (MaxConnsPerHost is unlimited). When they finish, it may only keep 2 of them; the other 8 are closed. The next burst opens 8 new connections, with fresh TCP and TLS handshakes, and closes 8 again afterward. Press the button a few times and compare the pool sizes:
Every closed connection costs twice. The next request pays the handshakes again — 2 round trips or more. And the side that closed the connection keeps a TIME_WAIT record of it for about a minute. At a few hundred requests per second you can pile up tens of thousands of them and run out of local ports for new outgoing connections. Your error logs then fill with connect: cannot assign requested address.
The fix is one line: set MaxIdleConnsPerHost to roughly your peak concurrency toward that host. Here is a client configured for production — every phase bounded, the pool sized:
func newClient() *http.Client {
tr := http.DefaultTransport.(*http.Transport).Clone() // keeps HTTP/2, proxy settings…
tr.DialContext = (&net.Dialer{
Timeout: 2 * time.Second, // TCP connect
KeepAlive: 30 * time.Second,
}).DialContext
tr.TLSHandshakeTimeout = 3 * time.Second // TLS handshake
tr.ResponseHeaderTimeout = 5 * time.Second // request sent → response headers arrive
tr.IdleConnTimeout = 90 * time.Second // unused connections leave the pool after this
tr.MaxIdleConns = 200 // pool size, all hosts together
tr.MaxIdleConnsPerHost = 100 // ← the default is 2!
tr.MaxConnsPerHost = 200 // optional hard cap per host (0 = unlimited)
return &http.Client{
Transport: tr,
Timeout: 10 * time.Second, // everything, including reading the body
}
}With HTTP/2 the pool matters less — one connection carries many requests at once — but Go still opens extra connections when a server's limit on simultaneous streams (often 100–250) is reached, so the pool settings still apply.
Node: Agent for http/https, a separate pool for fetch
Node's http and https modules use an http.Agent. Since Node 19, the global agent has keep-alive on by default, with no limit on sockets per host (maxSockets: Infinity) and up to 256 idle sockets kept per host. For a service that should cap its outgoing connections, create your own:
import http from "node:http";
export const agent = new http.Agent({
keepAlive: true,
maxSockets: 100, // at most 100 connections per host; further requests queue
maxFreeSockets: 100, // idle connections kept per host
timeout: 60_000, // close a socket that has been idle for 60 s
});
http.get("http://users.internal/users/7", { agent, signal: AbortSignal.timeout(10_000) }, (res) => {
res.resume(); // read (and here, discard) the body so the socket returns to the pool
});fetch does not use http.Agent. It's built on the undici library, which has its own pool (also keep-alive by default) and its own timeouts: 10 s to connect, and the 300 s header and body limits from §03. To tune it, install the undici package and pass a new Agent({ connections, headersTimeout, bodyTimeout, … }) as fetch's dispatcher option. Since installed and built-in versions can differ, check their compatibility first.
One timeout per phase
"The request timed out" can mean very different things. Every request goes through the same phases, and each phase fails in its own way:
http.Transport / http.Client setting that bounds it. Only Client.Timeout (or a context deadline) covers reading the body. A reused keep-alive connection skips the first three phases entirely.| Phase | What's slow when it times out | Go knob | Node / fetch |
|---|---|---|---|
| DNS + TCP connect | Host down, firewall dropping packets | net.Dialer{Timeout} | undici connect.timeout (10 s) |
| TLS handshake | Overloaded server, broken middlebox | TLSHandshakeTimeout (10 s default) | included in connect |
| Waiting for headers | The server is thinking — slow query, queueing | ResponseHeaderTimeout (none by default) | undici headersTimeout (300 s) |
| Reading the body | Big body, slow network, stalled stream | only Client.Timeout or the request's context | undici bodyTimeout (300 s) |
| Whole request | Any of the above | Client.Timeout, or context.WithTimeout | AbortSignal.timeout(ms) |
| Idle in the pool | — (cleanup, not an error) | IdleConnTimeout (90 s) | keepAliveTimeout / Agent timeout |
In production: server timeouts, proxies and httptrace
Slowloris: an attack made of patience
A server can't run your handler until it has read the request headers. A slowloris client opens a connection and sends headers one byte every few seconds, never finishing. Each such connection costs the server memory (and in thread-per-connection servers, a whole thread). A few thousand of them, from one laptop, and real users can't get in.
Go's http.Server has no timeouts by default. Set them:
srv := &http.Server{
Addr: ":8080",
Handler: mux,
ReadHeaderTimeout: 5 * time.Second, // ← the anti-slowloris one: whole header block
ReadTimeout: 30 * time.Second, // headers + body (uploads)
WriteTimeout: 30 * time.Second, // from end of headers until the response is written
IdleTimeout: 120 * time.Second, // keep-alive: close idle connections after this
MaxHeaderBytes: 1 << 20, // 1 MB (this is also the default)
}1 << 20 shifts the number 1 left by 20 binary places: 2²⁰ = 1,048,576 bytes. Linters like gosec flag a server without ReadHeaderTimeout.
Node has safer defaults (checked on Node 22): server.headersTimeout is 60 s (time allowed to receive the headers), server.requestTimeout is 300 s (time allowed to receive the entire request), and server.keepAliveTimeout is 5 s (an idle keep-alive connection is closed after this).
The mysterious 502: keep-alive timeouts behind a load balancer
A classic production bug. Your Node app sits behind a cloud load balancer that keeps idle connections to your app open for 60 s. Node closes idle connections after 5 s. Sometimes the load balancer sends a new request on a connection at the same moment Node closes it. The request is lost, and the user gets a 502 Bad Gateway — rare, random, impossible to reproduce locally.
The rule: the app's idle timeout must be longer than the proxy's. If the load balancer's is 60 s, set server.keepAliveTimeout = 65_000 in Node (and keep headersTimeout above it — older Node versions required that), or IdleTimeout: 75 * time.Second in Go. Then the proxy always closes first, and closing first is safe for it.
HTTP/2 at the edge, HTTP/1.1 behind it
Real traffic usually looks like this:
browser ──h3 / h2 over TLS──▶ CDN / load balancer ──HTTP/1.1 (or h2c)──▶ your Go/Node app
(the internet: long RTT, loss) (data center: ~1 ms RTT, no loss)The proxy at the edge does TLS termination (it decrypts HTTPS) and speaks whatever the browser prefers. Behind it, HTTP/1.1 with keep-alive is fine: round trips are short, loss is rare, and the proxy keeps a pool of connections open to each backend. This is exactly why HTTP/3's benefits rarely need to reach your app.
Things that bite at this boundary:
- The client's real address. Your app sees the proxy's IP address. The original one arrives in a header like
X-Forwarded-For(or the standardForwarded), and the original scheme inX-Forwarded-Proto: https. Only trust these headers when they're set by your proxy — a client can send them too. - HTTP/2 between proxy and app breaks naive balancing. With HTTP/1.1, a load balancer spreads requests across connections. A single long-lived HTTP/2 connection carries all of one client's requests to one backend, so a balancer that only picks a backend when a connection opens leaves the load lopsided. It matters most for gRPC, which always uses HTTP/2 — see Load balancing and gRPC.
- Timeouts are layered. The edge has its own idle and request timeouts. A 60 s proxy request timeout makes your 120 s handler limit irrelevant — users see a
504 Gateway Timeoutfirst.
Measure, don't guess: httptrace
When a request is slow, find out which phase was slow. Go's httptrace package calls your functions at each step of an outgoing request:
start := time.Now()
since := func() time.Duration { return time.Since(start).Round(100 * time.Microsecond) }
trace := &httptrace.ClientTrace{
DNSDone: func(httptrace.DNSDoneInfo) { fmt.Println(since(), "DNS done") },
ConnectDone: func(network, addr string, err error) { fmt.Println(since(), "TCP connected to", addr) },
TLSHandshakeDone: func(tls.ConnectionState, error) { fmt.Println(since(), "TLS handshake done") },
GotConn: func(i httptrace.GotConnInfo) { fmt.Println(since(), "got conn, reused:", i.Reused) },
GotFirstResponseByte: func() { fmt.Println(since(), "first response byte") },
}
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return err
}
req = req.WithContext(httptrace.WithClientTrace(req.Context(), trace)) // ← attach the callbacks
resp, err := client.Do(req)Real output for two requests to a local HTTPS server that takes 20 ms to answer:
--- first request
100µs DNS done
300µs TCP connected to 127.0.0.1:36427
1.9ms TLS handshake done
2ms got conn, reused: false
22.5ms first response byte ← 20 ms of server thinking
--- second request
0s got conn, reused: true ← keep-alive: no DNS, TCP or TLS at all
20.4ms first response byteIf reused is always false in production, you're paying handshakes on every request — check the pool size (§10) and that bodies are drained (§12). Without writing code, curl -w prints the same phases:
$ curl -s -o /dev/null -w "dns %{time_namelookup}s connect %{time_connect}s tls %{time_appconnect}s first-byte %{time_starttransfer}s total %{time_total}s\n" https://api.example.com/healthEach number is the time since the start, so the TLS handshake took tls − connect, and the server's thinking time is roughly first-byte − tls. In browsers, the Network tab's "Timing" panel shows the same breakdown, and its "Protocol" column (right-click the header row to add it) shows http/1.1, h2 or h3 per request. For tracing across many services, these phases become spans in a tracing system.
Pitfalls gallery
No timeout on the client
http.Get, http.Post and http.DefaultClient have no timeout at all; fetch without a signal waits up to 5 minutes in Node. One stuck dependency then piles up stuck goroutines or pending promises until your service falls over (§03).
resp, err := http.Get(url) // waits forever if the server never answersvar client = &http.Client{Timeout: 5 * time.Second}
resp, err := client.Get(url)Forgetting resp.Body.Close() in Go
An open body holds its connection. It can't go back to the pool, and Go keeps background goroutines alive to service it. Leak one per request and you'll eventually hit the operating system's limit on open connections per process (each one uses a file descriptor), or run out of memory. Every successful client.Do/Get needs a Close — even if you don't care about the body, and even for a 404.
resp, err := client.Get(url)
if err != nil {
return err
}
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("status %d", resp.StatusCode) // ← body never closed
}
defer resp.Body.Close()resp, err := client.Get(url)
if err != nil {
return err
}
defer resp.Body.Close() // ← right after the error check, before anything else
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("status %d", resp.StatusCode)
}Closing without draining: no reuse
If you Close a body you haven't read to the end, Go can't tell where the next response on that connection would start — so it throws the connection away. The next request pays the handshakes again. Real output from httptrace (§11), three requests per line, 1 MB responses. "drain 64 KB" reads at most 64 KB with io.LimitReader before closing; "drain, then close" is the fix shown below:
close only: reused=false reused=false reused=false
drain 64 KB: reused=false reused=false reused=false
drain, then close: reused=false reused=true reused=trueClose() it throws the connection away ✕ — the next request pays new handshakes.defer resp.Body.Close()
var u User
err = json.NewDecoder(resp.Body).Decode(&u)
// the decoder may stop before the end of the bodydefer func() {
io.Copy(io.Discard, resp.Body) // read the rest…
resp.Body.Close() // …now reusable
}()
var u User
err = json.NewDecoder(resp.Body).Decode(&u)io.Copy(io.Discard, r) reads r to the end and throws the bytes away. defer func() { … }() defers a whole anonymous function, so both lines run when the surrounding function returns.
If a response could be huge and you don't want the rest of it, cap the drain: io.Copy(io.Discard, io.LimitReader(resp.Body, 64<<10)) reads at most 64 KB (64<<10 is 64 × 1024). As the middle line of the output shows, a body bigger than the cap still costs you the connection — that's the deliberate trade: past a certain size, a new connection is cheaper than downloading megabytes you'll throw away. In Node, the equivalent is consuming the body (await res.text(), or res.body?.cancel() if you don't want it) for fetch, and res.resume() for http.get.
MaxIdleConnsPerHost left at 2
A service sending 50 concurrent requests to one backend keeps only 2 connections idle, and closes and reopens the rest on every burst — handshakes on the hot path, and thousands of TIME_WAIT sockets. Set MaxIdleConnsPerHost near your peak concurrency to that host (§10).
A new client (or Transport, or Agent) per request
The pool lives inside the Transport (Go) or Agent (Node). Creating one per request means every request starts with an empty pool — a new connection and new handshakes every time. Worse, a zero http.Transport{} has no idle timeout, so those connections (and the goroutines serving them) stay open until the server hangs up — a slow leak. The zero Transport also has no proxy support, no dial timeout and no TLS handshake timeout, so don't copy &http.Transport{} anywhere; start from http.DefaultTransport.(*http.Transport).Clone() (§09). Create one client at startup and share it; it's safe for concurrent use.
func getUser(id string) (*User, error) {
c := &http.Client{Transport: &http.Transport{}} // new pool every call
resp, err := c.Get(base + "/users/" + id)
// …
}var client = newClient() // once, at startup (§10)
func getUser(id string) (*User, error) {
resp, err := client.Get(base + "/users/" + id)
// …
}Treating 404 and 500 as success
Neither Go nor fetch reports an error for an HTTP error status: the exchange worked; the answer was bad. Code that only checks err happily parses an HTML error page as JSON.
const res = await fetch(url, { signal: AbortSignal.timeout(5000) });
const user = await res.json(); // 500? parses the error page, or throws a confusing SyntaxErrorconst res = await fetch(url, { signal: AbortSignal.timeout(5000) });
if (!res.ok) throw new Error(`GET ${url}: ${res.status}`); // ok = status 200–299
const user = await res.json();A Go server without ReadHeaderTimeout
http.ListenAndServe(":8080", mux) creates a server with no timeouts, open to slowloris. Build an http.Server and set at least ReadHeaderTimeout and IdleTimeout (§11).
Retrying POST blindly
A POST that timed out may have succeeded. Retrying it can create two orders or charge a card twice. Retry automatically only idempotent requests, or send an Idempotency-Key the server deduplicates on (§08).
App idle timeout shorter than the proxy's
Node's default keepAliveTimeout (5 s) behind a load balancer that keeps idle connections for 60 s causes rare, random 502s. Make the app's idle timeout longer than the proxy's (§11).
Check yourself
Commit to an answer before you click. If you miss one, the explanation points back to the section that covers it.
resp, err := http.Get("https://api.example.com/report")
if err != nil {
log.Fatal(err)
}client := &http.Client{Timeout: 5 * time.Second}
for i := 0; i < 3; i++ {
resp, err := client.Get(url)
if err != nil {
return err
}
resp.Body.Close() // body never read
}Cheat sheet
| Version | Transport | Requests per connection | Setup before first request (TLS 1.3) | A lost packet stalls… |
|---|---|---|---|---|
| HTTP/1.1 | TCP (+TLS) | One at a time | 2 RTT | that one connection |
| HTTP/2 | TCP + TLS | Many, interleaved | 2 RTT | every stream on the connection |
| HTTP/3 | QUIC over UDP (TLS built in) | Many, independent | 1 RTT (0 on resume) | only its own stream |
| If you need… | Go | Node / JS |
|---|---|---|
| A client with a timeout | &http.Client{Timeout: 5 * time.Second} | fetch(url, { signal: AbortSignal.timeout(5000) }) |
| A per-request deadline | http.NewRequestWithContext(ctx, …) | AbortSignal.any([sig, AbortSignal.timeout(ms)]) (Node 20.3+) |
| Connection reuse | one shared client, MaxIdleConnsPerHost: 100, drain + close bodies | new http.Agent({ keepAlive: true, maxSockets }); consume every body |
| HTTP/2 server | ListenAndServeTLS (automatic) | http2.createSecureServer({ allowHTTP1: true }) |
| h2c (no TLS) | http.Protocols.SetUnencryptedHTTP2 (Go 1.24+) | http2.createServer() |
| Server timeouts | ReadHeaderTimeout, IdleTimeout, ReadTimeout, WriteTimeout | headersTimeout, requestTimeout, keepAliveTimeout |
| See where time goes | net/http/httptrace | curl -w, browser Network tab → Timing |
| Debug by hand | curl -v, curl --http2, printf '…' | nc host 80 | same |