Skip to content
Software Atlas

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.

⏱ ~50 min read14 sections6 interactive widgetsGo · JavaScript
FoundationsPractitionerStaff-level
Foundations§01

The 30-second version

In one sentenceHTTP is the protocol your browser and your APIs use to ask for things: a request goes out, a response comes back. HTTP/1.1, 2 and 3 all mean exactly the same things — they only change how the bytes travel, each version fixing the bottleneck of the one before.

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.

Four files, four ways
HTTP/1.0
🤝
file 1
🤝
file 2
🤝
file 3
🤝
file 4
1.1 conn 1
🤝
file 1
file 3
1.1 conn 2
🤝
file 2
file 4
HTTP/2
🤝
HTTP/3
🤝
Grey = handshakes before any data can flow. Each color is one file. HTTP/2 and 3 cut files into pieces and interleave them on one connection. Illustrative, not to scale.

What each version fixed

VersionYear (standardized)The problem it fixedThe trick
HTTP/1.01996— (the original popular version)One request per connection, then hang up
HTTP/1.11997Opening a new connection for every file is slowKeep the connection open and reuse it (§04)
HTTP/22015Only one request at a time per connectionMany requests at once on one connection, cut into labelled pieces (§05)
HTTP/32022One lost packet stalls every request on an HTTP/2 connectionA new transport, QUIC, where each request recovers from loss on its own (§07)

The map of this page

  1. §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.
  2. §04–§07: the story of 1.1 → 2 → 3, each with an animation you can poke at.
  3. §08: caching and retries — the parts of HTTP that save you the most traffic.
  4. §09–§11: using HTTP/2 and HTTP/3 from Go and Node, then the production knobs: connection pools, timeouts, proxies.
  5. §12–§14: pitfalls, a quiz, and a cheat sheet.
Foundations§02

What an HTTP message looks like

In one sentenceAn HTTP/1.1 message is plain text: one first line saying what this is, some 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:

One request, down and up the layers
Step through it, then switch versions: the top layer's meaning never changes.
client
HTTP/1.1
what it means
TCP
one reliable, ordered byte stream
IP
addressed packets
the internet (routers)
server
HTTP/1.1
what it means
TCP
one reliable, ordered byte stream
IP
addressed packets
Your code asks for GET /hello. The HTTP/1.1 layer turns that into bytes: GET /hello HTTP/1.1␍␊Host: example.com␍␊␍␊.
1 / 6

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 intact

The parts, in order:

  • Request line — GET /hello?name=ada HTTP/1.1. The method (what to do), the path (which thing; the ?name=ada part is the query string), and the protocol version.
  • Headers — metadata. Host says which website you want (one server can host many); Accept says 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\n on 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.

HTTP message builder
Pick a method, log in or not, and click any line to see what it does.
Request (client → server)78 bytes
GET /api/users/7 HTTP/1.1
Host: api.example.com
Accept: application/json
(empty line)
Response (server → client) · 200156 bytes
HTTP/1.1 200 OK
Cache-Control: max-age=60
Content-Type: application/json
Date: Fri, 30 Oct 2026 10:32:14 GMT
Content-Length: 21
(empty line)
{"id":7,"name":"Ada"}
Request line. Method 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.1

printf 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

MethodMeansHas a body?Safe to retry blindly?
GETRead thisNoYes
HEADLike GET, but only send me the headersNoYes
POSTCreate something / submit thisYesNo — might create it twice
PUTReplace this with what I'm sendingYesYes (same result twice)
PATCHChange part of thisYesDepends on the change
DELETERemove thisUsually noYes (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.

ClassMeaningOnes you'll see every week
2xxIt worked200 OK, 201 Created, 204 No Content
3xxLook elsewhere301/308 moved permanently, 302/307 moved temporarily, 304 Not Modified (§08)
4xxYour request was wrong400 Bad Request, 401 not logged in, 403 not allowed, 404 Not Found, 429 Too Many Requests
5xxThe server failed500 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=abc123

The 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.

Foundations§03

Your first server and client — with timeouts

In one sentenceWriting an HTTP server or client takes ten lines in Go or Node. The line people forget is the timeout: by default, Go's client will wait forever, and Node's 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.

server.go
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

client.go
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 (what http.Get uses) has Timeout: 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: fetch has 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: fetch has 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 timeout
Practitioner§04

HTTP/1.1: keep-alive, and the six-connection hack

In one sentenceHTTP/1.1's big idea was reuse the connection. Its big limit: on one connection, requests still go strictly one at a time — so browsers open six connections per site and run six at once.

What a new connection costs

Before the first byte of HTTP can be sent, two handshakes have to happen:

  1. 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.
  2. 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.

Three files over HTTPS, 1 RTT = 100 ms
HTTP/1.0
TCP
TLS
GET a
TCP
TLS
GET b
TCP
TLS
GET c
keep-alive
TCP
TLS
GET a
GET b
GET c
pipelined
TCP
TLS
a (slow to generate)
b
c
b and c ready, but stuck behind a
HTTP/1.0 pays both handshakes for every file. Keep-alive pays them once. Pipelining sends all three requests at once, but responses must return in order — so fast b and c wait behind slow a.

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 part1 and, 100 ms later, part2 (the Date and Connection headers 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 finished

Sizes 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:

12 files over HTTP/1.1
Same 12 files, same network: 100 ms round trip, one shared link. A simplified model — times are approximate.
HTTP/1.1 · 6 TCP connections, one request at a time on each
0 ms
file 1 · c1
file 2 · c2
file 3 · c3
file 4 · c4
file 5 · c5
file 6 · c6
file 7 · c1
file 8 · c3
file 9 · c4
file 10 · c5
file 11 · c6
file 12 · c2
connection setup (handshakes)queued: no free connectionrequest sent, waiting for first byte (1 RTT)downloading (overlapping bars share the link)
Bars grow as time passes.

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.
Practitioner§05

HTTP/2: many streams on one connection

In one sentenceHTTP/2 cuts every request and response into small frames, each labelled with a stream number, and interleaves them on one connection. Many requests are in flight at once, and none has to wait for another to finish.

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:

Frames on one HTTP/2 connection
Three requests share one connection. Colors = streams. → client to server, ← server to client.
Code
1// Every HTTP/2 frame starts with this 9-byte header
2// (like FrameHeader in golang.org/x/net/http2)
3type FrameHeader struct {
4 Length uint32 // 24 bits: payload size
5 Type uint8 // DATA, HEADERS, SETTINGS…
6 Flags uint8 // e.g. END_STREAM
7 StreamID uint32 // which request it's part of
8}
9// …followed by Length bytes of payload
on the wire this step
→ SETTINGS · stream 0
← SETTINGS · stream 0
streams
no streams yet
files the browser has
nothing yet
One TCP + TLS connection is open. First, both sides send a SETTINGS frame — e.g. “I accept up to 100 streams at once”. Stream ID 0 means “this frame is about the connection itself”, not any request.
1 / 8

Two details from the stepper are worth remembering:

  • Client-started streams get odd numbers: 1, 3, 5… Node's http2 module shows it plainly: three requests on one connection print stream 1, stream 3, stream 5.
  • END_STREAM is a flag on the last frame of a stream. That's how the receiver knows a response is complete — no Content-Length or 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):

The race: HTTP/1.1 vs HTTP/2
Same 12 files, same network: 100 ms round trip, one shared link. A simplified model — times are approximate.
HTTP/1.1 · 6 TCP connections, one request at a time on each
0 ms
file 1 · c1
file 2 · c2
file 3 · c3
file 4 · c4
file 5 · c5
file 6 · c6
file 7 · c1
file 8 · c3
file 9 · c4
file 10 · c5
file 11 · c6
file 12 · c2
HTTP/2 · 1 TCP connection, all requests interleaved
0 ms
file 1
file 2
file 3
file 4
file 5
file 6
file 7
file 8
file 9
file 10
file 11
file 12
connection setup (handshakes)queued: no free connectionrequest sent, waiting for first byte (1 RTT)downloading (overlapping bars share the link)
Bars grow as time passes.

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.

Practitioner§06

Where HTTP/2 still hurts: TCP head-of-line blocking

In one sentenceHTTP/2 removed head-of-line blocking from HTTP, but not from TCP underneath. TCP hands bytes to the application strictly in order, so one lost packet freezes every stream on the connection until it's resent.

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):

One lost packet, two transports
Six packets carry data for three streams (A, B, C). Packet 3 is lost on the way.
Arrived at the receiving computerin the OS buffer
#1 · A#2 · B#3 · C · lost ✕#4 · A#5 · B#6 · A
stream A → appwaiting
#1#4 held#6 held
stream B → appwaiting
#2#5 held
stream C → appwaiting
TCP only knows one ordered byte stream. Packets 4–6 have arrived, but TCP may not hand them over until #3 fills the gap — so streams A and B wait for C's lost packet.

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:

The race: HTTP/1.1 vs HTTP/2
Same 12 files, same network: 100 ms round trip, one shared link. A simplified model — times are approximate.
HTTP/1.1 · 6 TCP connections, one request at a time on each
0 ms
file 1 · c1
file 2 · c2
file 3 · c3
file 4 · c4
file 5 · c5
file 6 · c6
file 7 · c1
file 8 · c3
file 9 · c4
file 10 · c5
file 11 · c6
file 12 · c2
HTTP/2 · 1 TCP connection, all requests interleaved
0 ms
file 1
file 2
file 3
file 4
file 5
file 6
file 7
file 8
file 9
file 10
file 11
file 12
connection setup (handshakes)queued: no free connectionrequest sent, waiting for first byte (1 RTT)downloading (overlapping bars share the link)stalled: waiting for a lost packet to be resent
Bars grow as time passes.

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.

Practitioner§07

HTTP/3 and QUIC

In one sentenceHTTP/3 keeps HTTP/2's idea — many streams on one connection — but runs it on QUIC, a new transport built on UDP. QUIC knows about streams, so a lost packet only delays its own stream. It also merges the connection and encryption handshakes into one round trip.

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.

What each version runs on
HTTP/1.1
HTTP/1.1
text messages
TLS
optional (https)
TCP
one ordered byte stream
IP
HTTP/2
HTTP/2
binary frames, streams
TLS
in practice, always
TCP
one ordered byte stream
IP
HTTP/3
HTTP/3
frames (no stream IDs: QUIC has them)
QUIC
streams · reliability · TLS 1.3 built in
UDP
just packets
IP
Read each column top to bottom: each layer hands its bytes to the one below. HTTP/3's QUIC layer does the jobs that TCP and TLS did — reliability, ordering (per stream), congestion control, encryption — on top of plain UDP.

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.

Time until the first response arrives
01 RTT2 RTT3 RTT4 RTT
TCP+TLS 1.2
TCP
TLS 1.2 (2 RTT)
request
TCP+TLS 1.3
TCP
TLS 1.3
request
QUIC (h3)
QUIC + TLS
request
QUIC 0-RTT
setup + request
Each block is one round trip. The green block is the HTTP request going out and its response coming back. QUIC does connection setup and TLS 1.3 together; 0-RTT (only when resuming a recent connection) sends the request in the very first packet.

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.

Replaying a 0-RTT packet
Pick a request and the server's policy, then step through.
client
0-RTT: POST /transfer amount=500
attacker
listening…
serverbalance €1000
waiting…
The client resumes a connection and sends POST /transfer amount=500 inside its very first packet: 0-RTT early data.
1 / 5

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:

  1. The first visit uses HTTP/2 (or 1.1) over TCP, as normal.
  2. 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)".
  3. 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:

The race: HTTP/1.1 vs HTTP/2 vs HTTP/3
Same 12 files, same network: 100 ms round trip, one shared link. A simplified model — times are approximate.
HTTP/1.1 · 6 TCP connections, one request at a time on each
0 ms
file 1 · c1
file 2 · c2
file 3 · c3
file 4 · c4
file 5 · c5
file 6 · c6
file 7 · c1
file 8 · c3
file 9 · c4
file 10 · c5
file 11 · c6
file 12 · c2
HTTP/2 · 1 TCP connection, all requests interleaved
0 ms
file 1
file 2
file 3
file 4
file 5
file 6
file 7
file 8
file 9
file 10
file 11
file 12
HTTP/3 · 1 QUIC connection, independent streams
0 ms
file 1
file 2
file 3
file 4
file 5
file 6
file 7
file 8
file 9
file 10
file 11
file 12
connection setup (handshakes)queued: no free connectionrequest sent, waiting for first byte (1 RTT)downloading (overlapping bars share the link)stalled: waiting for a lost packet to be resent
Bars grow as time passes.

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.
Practitioner§08

Caching, conditional requests and safe retries

In one sentenceThe fastest request is the one you never send (a fresh cached copy). The second fastest is the one where the server says "304: nothing changed, use what you have". Both are plain HTTP headers, and they work the same in every version.

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:MeansTypical use
max-age=31536000, immutableReuse for a year without asking; it will never changeapp.3f9a1c.js — the hash in the name changes when the content does
max-age=60Reuse for 60 s, then ask againA product listing that can be a minute stale
no-cacheYou may store it, but check with me every time before using itHTML pages, API responses that must be current
no-storeDon't store it at allBank statements, anything private and sensitive
privateOnly the user's own browser may store it, not shared caches like CDNsPages 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.

ETag and 304, step by step
How a browser checks whether its cached copy is still current.
Code
1# First visit
2GET /users/7 HTTP/1.1
3
4HTTP/1.1 200 OK
5ETag: "v42"
6Cache-Control: no-cache
7Content-Length: 21
8
9{"id":7,"name":"Ada"}
10
11# Later: is my copy still current?
12GET /users/7 HTTP/1.1
13If-None-Match: "v42"
14
15HTTP/1.1 304 Not Modified
16ETag: "v42"
browser cache
empty
server
user 7 · version v42
over the network
→ request · ~60 B
The browser has never seen /users/7, so its cache is empty. It sends a normal GET.
1 / 6

Here's the server side, by hand so you can see every step:

etag.go
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.
  • POST and most PATCH are 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.

Practitioner§09

HTTP/2 and HTTP/3 in Go and Node

In one sentenceYou rarely "write HTTP/2". In Go it switches on by itself whenever TLS is involved; in Node it's a separate module. HTTP/3 is still mostly something your CDN or proxy speaks for you.

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.

server_tls.go
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 client

The client side is automatic too: http.Client uses HTTP/2 with any HTTPS server that supports it — unless you customize the transport.

trap

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.0
tr := &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:

h2c.go
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:

server-h2.mjs
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

ServerClient
Browsers—Yes, all major ones
GoNot 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
NodeNo 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
Staff-level§10

Connection pools and the timeout taxonomy

In one sentenceAn HTTP client is really a connection pool: a set of open connections it reuses. Size the pool for your concurrency, and give every phase of a request its own timeout — otherwise one slow phase eats the whole budget.

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 fieldDefault (DefaultTransport)Meaning
MaxIdleConns100Idle connections kept, all hosts together
MaxIdleConnsPerHost2 (when left at 0)Idle connections kept per host
MaxConnsPerHost0 = unlimitedHard cap on connections per host, busy or idle
IdleConnTimeout90 sClose 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:

Connection pool: MaxIdleConnsPerHost
Each burst sends 10 concurrent requests to one host, like a busy backend calling its database API.
open connections to api.internal0
none yet
closed · TIME_WAIT0
none
0
new connections
0
reused
≈ 0 ms
handshake time spent
handshake ≈ 2 RTT × 100 ms per new connection · TIME_WAIT entries fade over ~3 bursts (≈ 60 s)
Press the button: 10 requests to the same host run at once.

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:

client.go
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:

agent.mjs
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:

The phases of one request, and which Go setting limits each
phase
DNS
TCP
TLS
send
wait for headers (server thinks)
read body
idle in pool
Transport
Dialer.Timeout
TLSHandshake…
ResponseHeaderTimeout
(nothing)
IdleConn…
Client
Client.Timeout / context deadline: the whole request, body included
Top row: what happens. Below: the Go 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.
PhaseWhat's slow when it times outGo knobNode / fetch
DNS + TCP connectHost down, firewall dropping packetsnet.Dialer{Timeout}undici connect.timeout (10 s)
TLS handshakeOverloaded server, broken middleboxTLSHandshakeTimeout (10 s default)included in connect
Waiting for headersThe server is thinking — slow query, queueingResponseHeaderTimeout (none by default)undici headersTimeout (300 s)
Reading the bodyBig body, slow network, stalled streamonly Client.Timeout or the request's contextundici bodyTimeout (300 s)
Whole requestAny of the aboveClient.Timeout, or context.WithTimeoutAbortSignal.timeout(ms)
Idle in the pool— (cleanup, not an error)IdleConnTimeout (90 s)keepAliveTimeout / Agent timeout
Staff-level§11

In production: server timeouts, proxies and httptrace

In one sentenceServers need timeouts as much as clients do — or a handful of slow clients can hold every connection. And in production there's almost always a proxy in front, speaking a different HTTP version to the browser than to your app.

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.

Slowloris vs a header timeout
attacker
G
E
T
…forever
no limit
connection held open, waiting for the rest of the headers
5 s limit
waiting…
closed ✓
Without a limit, the server waits as long as the client likes. With ReadHeaderTimeout: 5s, it hangs up and frees the connection.

Go's http.Server has no timeouts by default. Set them:

server.go
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 standard Forwarded), and the original scheme in X-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 Timeout first.

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:

trace.go
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 byte

If 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/health

Each 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.

Practitioner§12

Pitfalls gallery

#1

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).

✕ Don't
resp, err := http.Get(url) // waits forever if the server never answers
✓ Do
var client = &http.Client{Timeout: 5 * time.Second}

resp, err := client.Get(url)
#2

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.

✕ Don't
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()
✓ Do
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)
}
#3

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=true
One connection, seen as a tape of bytes
Response A's body is followed, on the same connection, by response B. Go can only reuse the connection if it knows where B starts.
A hdrs
A body · 1 MB
B: 200…
green = bytes your code readwhite line = where reading stopped
The JSON decoder read the 2 KB it needed and stopped. 1022 KB of A are still unread on the connection, so Go can't tell where B would start. On Close() it throws the connection away ✕ — the next request pays new handshakes.
✕ Don't
defer resp.Body.Close()
var u User
err = json.NewDecoder(resp.Body).Decode(&u)
// the decoder may stop before the end of the body
✓ Do
defer 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.

#4

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).

#5

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.

✕ Don't
func getUser(id string) (*User, error) {
    c := &http.Client{Transport: &http.Transport{}} // new pool every call
    resp, err := c.Get(base + "/users/" + id)
    // …
}
✓ Do
var client = newClient() // once, at startup (§10)

func getUser(id string) (*User, error) {
    resp, err := client.Get(base + "/users/" + id)
    // …
}
#6

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.

✕ Don't
const res = await fetch(url, { signal: AbortSignal.timeout(5000) });
const user = await res.json(); // 500? parses the error page, or throws a confusing SyntaxError
✓ Do
const 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();
#7

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).

#8

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).

#9

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).

Practitioner§13

Check yourself

Commit to an answer before you click. If you miss one, the explanation points back to the section that covers it.

QUESTION 1 / 7
A page loads 30 small images from one host over HTTP/1.1 in Chrome. How many are being downloaded at the same moment, at most?
QUESTION 2 / 7
The server completes the TLS handshake, reads the request, and then never sends a response. What does this code do?
resp, err := http.Get("https://api.example.com/report")
if err != nil {
    log.Fatal(err)
}
QUESTION 3 / 7
One packet carrying data for stream 7 is lost. Which streams have to wait for the resend?
QUESTION 4 / 7
Each response is 1 MB. How many TCP connections does this loop open?
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
}
QUESTION 5 / 7
A response had Cache-Control: no-cache and ETag: "v9". The page needs it again. What does the browser do?
QUESTION 6 / 7
Which request is acceptable to send as HTTP/3 0-RTT early data?
QUESTION 7 / 7
10 requests to one host run at the same time using Go's DefaultTransport. When they're all done, how many connections stay open in the idle pool?
Answered 0 / 7
Foundations§14

Cheat sheet

✉️
One meaning, three wire formatsMethod, path, headers, status, body are the same in 1.1, 2 and 3. Your handlers don't change.
🔁
1.1: keep-alive, one at a timeReuse the connection, but one request per connection at a time → browsers open 6 per host.
🧵
2: streams and framesMany requests interleaved on one TCP connection; HPACK shrinks headers. Server push is gone.
🧱
TCP head-of-line blockingOne lost packet stalls every HTTP/2 stream until it's resent.
🚀
3: QUIC over UDPLoss stalls only its own stream; 1-RTT setup (0-RTT on resume, safe requests only); survives network changes.
🗄️
Cache: max-age, ETag, 304no-cache = revalidate; no-store = never store. Retry only safe/idempotent requests, or use an Idempotency-Key.
⏱️
Every client gets a timeoutGo's default client waits forever; Node's fetch waits 300 s for headers (and 300 s of silence per body gap). Add per-phase limits plus an overall deadline.
🏊
Pool it, drain it, close itOne shared client. MaxIdleConnsPerHost ≫ 2. Drain and close every Go body.
VersionTransportRequests per connectionSetup before first request (TLS 1.3)A lost packet stalls…
HTTP/1.1TCP (+TLS)One at a time2 RTTthat one connection
HTTP/2TCP + TLSMany, interleaved2 RTTevery stream on the connection
HTTP/3QUIC over UDP (TLS built in)Many, independent1 RTT (0 on resume)only its own stream
If you need…GoNode / JS
A client with a timeout&http.Client{Timeout: 5 * time.Second}fetch(url, { signal: AbortSignal.timeout(5000) })
A per-request deadlinehttp.NewRequestWithContext(ctx, …)AbortSignal.any([sig, AbortSignal.timeout(ms)]) (Node 20.3+)
Connection reuseone shared client, MaxIdleConnsPerHost: 100, drain + close bodiesnew http.Agent({ keepAlive: true, maxSockets }); consume every body
HTTP/2 serverListenAndServeTLS (automatic)http2.createSecureServer({ allowHTTP1: true })
h2c (no TLS)http.Protocols.SetUnencryptedHTTP2 (Go 1.24+)http2.createServer()
Server timeoutsReadHeaderTimeout, IdleTimeout, ReadTimeout, WriteTimeoutheadersTimeout, requestTimeout, keepAliveTimeout
See where time goesnet/http/httptracecurl -w, browser Network tab → Timing
Debug by handcurl -v, curl --http2, printf '…' | nc host 80same
Finished reading?Mark it complete to track your progress on a roadmap. Saved in this browser only.
Up next in NetworkingTLS & HTTPS
Read →