What Actually Happens When You Run curl https://example.com
You type curl https://example.com and hit Enter. HTML appears. You've done this thousands of times. But do you know what actually happened between keystroke and output?
Most developers don't. They know the names — DNS, TCP, TLS, HTTP/2 — but they've never seen the full sequence laid out end to end, with real curl -v output showing exactly what each layer does and when.
Here's the complete picture.
The Four Layers, In Order
Every HTTPS request runs through four independent layers, each doing one job:
DNS → TCP → TLS → HTTP/2
The output of one layer becomes the input of the next. curl -v prints them top to bottom, in that exact order.
A real request to example.com:
$ curl -v https://example.com
* Host example.com:443 was resolved.
* IPv6: 2606:4700:90c5:72db:f264:5bb:ef6b:ff98
* IPv4: 172.66.147.243, 104.20.23.154
* Trying [2606:4700:90c5:72db:f264:5bb:ef6b:ff98]:443...
* Connected to example.com (2606:4700:90c5:72db:f264:5bb:ef6b:ff98) port 443
* ALPN: curl offers h2,http/1.1
* TLSv1.3 (OUT), TLS handshake, Client hello (1):
* TLSv1.3 (IN), TLS handshake, Server hello (2):
* TLSv1.3 (IN), TLS handshake, Encrypted Extensions (8):
* TLSv1.3 (IN), TLS handshake, Certificate (11):
* TLSv1.3 (IN), TLS handshake, CERT verify (15):
* TLSv1.3 (IN), TLS handshake, Finished (20):
* TLSv1.3 (OUT), TLS change cipher, Change cipher spec (1):
* TLSv1.3 (OUT), TLS handshake, Finished (20):
* SSL connection using TLSv1.3 / TLS_AES_256_GCM_SHA384 / X25519 / id-ecPublicKey
* ALPN: server accepted h2
* Server certificate:
* subject: CN=example.com
* start date: Jul 29 22:10:08 2026 GMT
* expire date: Oct 27 22:17:21 2026 GMT
* subjectAltName: host "example.com" matched cert's "example.com"
* issuer: C=US; O=SSL Corporation; CN=Cloudflare TLS Issuing ECC CA 3
* SSL certificate verify ok.
* using HTTP/2
* [HTTP/2] [1] OPENED stream for https://example.com/
* [HTTP/2] [1] [:method: GET]
* [HTTP/2] [1] [:scheme: https]
* [HTTP/2] [1] [:authority: example.com]
* [HTTP/2] [1] [:path: /]
* [HTTP/2] [1] [user-agent: curl/8.5.0]
* [HTTP/2] [1] [accept: */*]
> GET / HTTP/2
> Host: example.com
> User-Agent: curl/8.5.0
> Accept: */*
>
< HTTP/2 200
< date: Fri, 04 Sep 2026 13:28:04 GMT
< content-type: text/html
< server: cloudflare
< last-modified: Sun, 30 Aug 2026 04:11:49 GMT
< allow: GET, HEAD
< accept-ranges: bytes
< age: 8141
< cf-cache-status: HIT
< cf-ray: a35d55d42b94efe6-DEL
That's the whole thing. Now let's break down what each section actually means.
Layer 1: DNS — "Was Resolved"
* Host example.com:443 was resolved.
* IPv6: 2606:4700:90c5:72db:f264:5bb:ef6b:ff98
* IPv4: 172.66.147.243, 104.20.23.154
What happens: curl asks the OS resolver for example.com. The resolver checks its cache, then your OS cache, then your configured DNS server (usually your ISP or something like 1.1.1.1/8.8.8.8). That resolver walks the DNS hierarchy: root → .com TLD → example.com authoritative nameservers → IP address.
What developers get wrong: They think DNS is "one lookup." It's a chain of delegated queries. The resolver you hit is just the first hop. Also: curl -v shows the result, not the process. The "was resolved" line appears after the full recursive chain completes.
Happy Eyeballs: Notice both IPv6 and IPv4 addresses returned. curl tries IPv6 first (RFC 8305 "Happy Eyeballs"), falls back to IPv4 if it fails. This happens automatically — you don't configure it.
Layer 2: TCP — The Three-Way Handshake
* Trying [2606:4700:90c5:72db:f264:5bb:ef6b:ff98]:443...
* Connected to example.com (2606:4700:90c5:72db:f264:5bb:ef6b:ff98) port 443
What happens: curl opens a socket, sends a TCP SYN to port 443. The server replies SYN-ACK. curl sends ACK. Connection established. This is one round trip (1 RTT).
What developers get wrong: They conflate "connection" with "TLS handshake." TCP establishes a reliable byte stream. TLS establishes encryption. Separate layers. You can have TCP without TLS (HTTP), but not HTTPS without TCP.
Connection reuse: On the second request to the same host, you'll see connect: 0.000000s — the TCP connection was kept alive. HTTP/2 multiplexes many streams over one TCP connection, eliminating the 6-connection limit of HTTP/1.1.
Layer 3: TLS 1.3 — One Round Trip to Encryption
* ALPN: curl offers h2,http/1.1
* TLSv1.3 (OUT), TLS handshake, Client hello (1):
* TLSv1.3 (IN), TLS handshake, Server hello (2):
* TLSv1.3 (IN), TLS handshake, Encrypted Extensions (8):
* TLSv1.3 (IN), TLS handshake, Certificate (11):
* TLSv1.3 (IN), TLS handshake, CERT verify (15):
* TLSv1.3 (IN), TLS handshake, Finished (20):
* TLSv1.3 (OUT), TLS change cipher, Change cipher spec (1):
* TLSv1.3 (OUT), TLS handshake, Finished (20):
* SSL connection using TLSv1.3 / TLS_AES_256_GCM_SHA384 / X25519 / id-ecPublicKey
* ALPN: server accepted h2
* Server certificate: ...
* SSL certificate verify ok.
What happens (TLS 1.3 = 1 RTT):
-
ClientHello — curl sends its supported cipher suites, key share (X25519), and ALPN protocols (
h2,http/1.1) -
ServerHello — server picks cipher (TLS_AES_256_GCM_SHA384), sends its key share, selects
h2via ALPN -
Encrypted Extensions — server sends extensions that don't affect crypto (like
server_name,max_fragment_length) - Certificate — server sends its cert chain (leaf → intermediate → root)
- CertificateVerify — server proves it owns the private key (signature over handshake transcript)
- Finished — server sends MAC over entire handshake, confirming integrity
- Client Finished — curl verifies everything, sends its own Finished
After this, everything is encrypted. The HTTP request/response you see next? They're inside TLS records. A packet capture shows only TLS application data frames — no HTTP visible.
What developers get wrong:
- "TLS is part of TCP" — No. TLS runs on top of TCP. Different layers.
- "The certificate contains the private key" — Never. The cert has the public key. The private key never leaves the server.
- "ALPN negotiates HTTP/2" — ALPN selects the application protocol during the TLS handshake, before any HTTP bytes are sent. That's why
h2appears in the ClientHello. - "Change Cipher Spec means something in TLS 1.3" — It doesn't. It's a compatibility artifact for middleboxes that expect it from TLS 1.2. Ignore it.
Session tickets: At the end, the server sends NewSessionTicket messages. These let the client resume the session on the next connection with 0-RTT — sending application data with the ClientHello, skipping the handshake entirely. That's why the second request shows tls: 0.000000s.
Layer 4: HTTP/2 — Binary Frames, Multiplexed Streams
* using HTTP/2
* [HTTP/2] [1] OPENED stream for https://example.com/
* [HTTP/2] [1] [:method: GET]
* [HTTP/2] [1] [:scheme: https]
* [HTTP/2] [1] [:authority: example.com]
* [HTTP/2] [1] [:path: /]
* [HTTP/2] [1] [user-agent: curl/8.5.0]
* [HTTP/2] [1] [accept: */*]
> GET / HTTP/2
> Host: example.com
> User-Agent: curl/8.5.0
> Accept: */*
>
< HTTP/2 200
< date: Fri, 04 Sep 2026 13:28:04 GMT
< content-type: text/html
...
What happens: HTTP/2 is a binary framing layer over TLS. The client sends a 24-byte connection preface (PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n), then a SETTINGS frame. The server replies with its SETTINGS. Then both sides exchange frames on streams (identified by stream ID).
Key frames you're seeing:
-
HEADERSframe — carries pseudo-headers (:method,:path,:authority,:scheme) + regular headers -
DATAframe — carries request/response body -
SETTINGS— connection configuration (max frame size, header table size, etc.) -
WINDOW_UPDATE— flow control (each stream + connection has a receive window)
Multiplexing: Multiple requests/responses interleave on the same TCP connection. Stream 1, Stream 3, Stream 5 — frames from all of them mixed together. The receiver reassembles by stream ID. No head-of-line blocking at the HTTP layer (though TCP head-of-line blocking still exists — that's what HTTP/3/QUIC solves).
What developers get wrong:
- "HTTP/2 is just HTTP/1.1 with compression" — No. It's a completely different wire format. Binary frames, not text lines.
- "Header compression = gzip" — No. HPACK (RFC 7541) is a stateful compression: both ends maintain a dynamic header table. Repeated headers (like
user-agent) become tiny references. - "The
>and<lines incurl -vare HTTP/1.1 format" — curl displays them that way for readability. On the wire, they're binary HEADERS frames.
The Timing Breakdown (What curl -w Shows)
$ curl -w "dns: %{time_namelookup}s\nconnect: %{time_connect}s\ntls: %{time_appconnect}s\npretransfer: %{time_pretransfer}s\nttfb: %{time_starttransfer}s\ntotal: %{time_total}s\n" -o /dev/null -s https://example.com
dns: 0.191242s
connect: 0.414582s
tls: 1.098157s
pretransfer: 1.098352s
ttfb: 1.726874s
total: 1.726993s
Read as deltas (this is how you actually debug):
| Phase | Calculation | Time | What It Means |
|---|---|---|---|
| DNS | time_namelookup |
191ms | Resolver chain latency |
| TCP | time_connect - time_namelookup |
223ms | 1 RTT to server |
| TLS | time_appconnect - time_connect |
684ms | TLS 1.3 handshake (1 RTT + crypto) |
| Server | time_starttransfer - time_appconnect |
629ms | Server processing + network return |
| Transfer | time_total - time_starttransfer |
~0ms | Body download (tiny response) |
Second request (connection reused + session resumption):
dns: 0.000028s
connect: 0.000000s
tls: 0.000000s
pretransfer: 0.000079s
ttfb: 0.091438s
total: 0.091513s
Zero DNS, zero TCP, zero TLS — the connection was alive and the TLS session resumed via 0-RTT ticket. Only TTFB remains (91ms = server processing + 1 RTT).
Common Misconceptions, Debunked
| Misconception | Reality |
|---|---|
| "DNS resolves to one IP" | Returns multiple (IPv4 + IPv6). Client picks via Happy Eyeballs. |
| "TLS handshake is 2 round trips" | TLS 1.2 was 2 RTT. TLS 1.3 is 1 RTT (or 0-RTT on resume). |
| "HTTP/2 requires HTTPS" | Technically h2c (cleartext HTTP/2) exists via Upgrade, but no browser supports it. In practice: yes, HTTPS only. |
| "ALPN is optional" | For HTTPS, mandatory. Without ALPN, server doesn't know to speak HTTP/2. |
| "Certificate verification happens after handshake" | It's part of the handshake. CertificateVerify proves possession of private key. |
| "The response headers are text" | On the wire: binary HEADERS frames with HPACK encoding. |
Mental Model Checklist
Next time you debug a slow or failing request, map it to the layer:
-
DNS slow? →
dig +trace example.com, check TTL, try different resolver -
TCP slow? → Check RTT (
ping), firewall drops (SYN retransmits in capture) -
TLS slow? →
openssl s_client -connect example.com:443 -tls1_3, verify TLS 1.3 enabled, check session resumption -
TTFB slow? →
curl -Ito checkCache-Control, profile origin with OpenTelemetry -
Total slow but TTFB fast? → Large body, enable Brotli/gzip, check
Content-LengthvsTransfer-Encoding: chunked
Why This Matters
You use curl daily. Your apps make HTTPS requests constantly. But the mental model most developers carry is fuzzy: "DNS → magic → response."
When production latency spikes, the difference between "it's the network" and "it's the database" is knowing which layer the time lives in. curl -w gives you that breakdown in one command. Packet captures (tcpdump -i eth0 -w trace.pcap host example.com and port 443) let you verify each layer on the wire.
The stack hasn't changed in decades. DNS → TCP → TLS → HTTP. What changes is which layer is the bottleneck today.
Now you can point at the exact line in curl -v output and say: "This is where the time goes."
TL;DR
- DNS resolves the name → IP (recursive chain, cached at multiple levels)
- TCP 3-way handshake → reliable byte stream (1 RTT, reusable via keep-alive)
- TLS 1.3 1-RTT handshake → encrypted channel + ALPN selects HTTP/2 (resumable via 0-RTT tickets)
- HTTP/2 binary frames on multiplexed streams → request/response inside TLS records
Run curl -v and curl -w on your own endpoints. Map the output to the layers. That's the mental model that turns "it's slow" into "TLS handshake is 400ms because session resumption isn't working."
Top comments (0)