Chapter 17 — Debugging the lifecycle: browser DevTools, curl, OpenSSL, packet capture, and logs
In one sentence
Debugging HTTPS means locating the failing layer first, then using the least powerful tool that can prove or disprove your hypothesis.
Why this matters
The same symptom ("page is slow" or "site is down") can come from DNS, TCP, TLS, HTTP routing, cache, CDN, browser rendering, service-worker behavior, backend latency, or security policy. Guessing wastes incident time. A request is a sequence of phases, and each phase has a tool that can time it.
How it actually works
1. Time the phases. Every HTTPS request has the same timeline:
start ── DNS ── TCP connect ── TLS handshake ── request sent ── first byte ── last byte
└ resolver ┘ └ 1 RTT ┘ └ 1 RTT (TLS 1.3) ┘ └ server think + 1 RTT ┘ └ size/bandwidth ┘
A slow phase tells you which team or system to look at. That's the whole method.
2. Use a ladder of tools, cheapest first:
# DNS
dig example.com A
dig example.com AAAA
# TCP/TLS/HTTP timing
curl -v -w 'dns=%{time_namelookup} connect=%{time_connect} tls=%{time_appconnect} ttfb=%{time_starttransfer} total=%{time_total}\n' -o /dev/null https://example.com
# Certificate
openssl s_client -connect example.com:443 -servername example.com </dev/null
# Headers and cache/security policy
curl -sI https://example.com
# Bypass DNS and hit ONE specific edge or backend, keeping SNI and Host correct
curl -v --resolve example.com:443:203.0.113.10 https://example.com/
curl -w values are cumulative: subtract consecutive ones to get each phase.
3. Know what each vantage point can see:
| Tool | Sees | Can't see |
|---|---|---|
| Browser DevTools | Service workers, CORS, cache mode, priorities, render blocking, main-thread tasks, layout shifts | What happened after the edge |
curl / httptrace |
Exact phase timings, raw headers, protocol, connection reuse | Browser policy (CORS, CSP, cookies' SameSite) |
| CDN / LB logs | Which POP, cache status, upstream timing, the status the edge returned | The browser's experience |
| App logs and traces | What the backend saw after proxy rewriting, and its own latency | Anything that never reached it |
| Packet capture | Ground truth on the wire: loss, retransmits, resets, timing | Content inside TLS (without key logs) |
Build it in Go (30 min) — curl -w you can extend
Go's net/http/httptrace package calls you back at every phase of a request.
httptrace turns those callbacks into a timing breakdown, and then prints the
response headers that reveal the path (CDN, cache, protocol upgrades). With
-n it sends several requests on one client, so you can watch connection
reuse:
// httptrace: `curl -w` in Go -- time every phase of an HTTPS request and show
// what the response says about caches, servers, and protocols on the way.
//
// go run ./httptrace https://example.com/
// go run ./httptrace -n 3 https://www.cloudflare.com/ # watch connection reuse
package main
import (
"crypto/tls"
"flag"
"fmt"
"io"
"net/http"
"net/http/httptrace"
"os"
"strings"
"time"
)
func main() {
n := flag.Int("n", 1, "number of sequential requests (shows connection reuse)")
flag.Parse()
if flag.NArg() != 1 {
fmt.Println("usage: httptrace [-n N] URL")
os.Exit(2)
}
client := &http.Client{Timeout: 15 * time.Second} // one client = one connection pool
for i := 1; i <= *n; i++ {
if err := trace(client, flag.Arg(0), i); err != nil {
fmt.Println("error:", err)
os.Exit(1)
}
}
}
func trace(client *http.Client, url string, i int) error {
var start, dnsStart, dnsDone, connStart, connDone, tlsStart, tlsDone, gotConn, wrote, firstByte time.Time
var reused bool
var remote string
var tlsState *tls.ConnectionState
ct := &httptrace.ClientTrace{
DNSStart: func(httptrace.DNSStartInfo) { dnsStart = time.Now() },
DNSDone: func(httptrace.DNSDoneInfo) { dnsDone = time.Now() },
ConnectStart: func(_, _ string) { connStart = time.Now() },
ConnectDone: func(_, addr string, _ error) { connDone, remote = time.Now(), addr },
TLSHandshakeStart: func() { tlsStart = time.Now() },
TLSHandshakeDone: func(s tls.ConnectionState, _ error) {
tlsDone, tlsState = time.Now(), &s
},
GotConn: func(info httptrace.GotConnInfo) {
gotConn, reused = time.Now(), info.Reused
if remote == "" {
remote = info.Conn.RemoteAddr().String()
}
},
WroteRequest: func(httptrace.WroteRequestInfo) { wrote = time.Now() },
GotFirstResponseByte: func() { firstByte = time.Now() },
}
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return err
}
req = req.WithContext(httptrace.WithClientTrace(req.Context(), ct))
start = time.Now()
resp, err := client.Do(req)
if err != nil {
return err
}
body, _ := io.Copy(io.Discard, resp.Body)
resp.Body.Close()
end := time.Now()
ms := func(a, b time.Time) string {
if a.IsZero() || b.IsZero() {
return " - "
}
return fmt.Sprintf("%7.1fms", float64(b.Sub(a).Microseconds())/1000)
}
fmt.Printf("request #%d %s %s reused=%v remote=%s\n", i, resp.Proto, resp.Status, reused, remote)
fmt.Printf(" dns %s\n", ms(dnsStart, dnsDone))
fmt.Printf(" tcp %s\n", ms(connStart, connDone))
fmt.Printf(" tls %s", ms(tlsStart, tlsDone))
if tlsState != nil {
fmt.Printf(" %s, ALPN=%q, resumed=%v", tls.VersionName(tlsState.Version), tlsState.NegotiatedProtocol, tlsState.DidResume)
}
fmt.Println()
fmt.Printf(" got conn %s (time to a usable connection)\n", ms(start, gotConn))
fmt.Printf(" ttfb %s (request written -> first byte: server think time + 1 RTT)\n", ms(wrote, firstByte))
fmt.Printf(" download %s (%d bytes)\n", ms(firstByte, end), body)
fmt.Printf(" total %s\n", ms(start, end))
// What the headers reveal about the path.
for _, h := range []string{"Server", "Via", "Age", "Cache-Control", "CF-Cache-Status", "X-Cache",
"Alt-Svc", "Strict-Transport-Security", "Server-Timing", "Content-Encoding"} {
if v := resp.Header.Get(h); v != "" {
fmt.Printf(" %-26s %s\n", h+":", strings.TrimSpace(v))
}
}
fmt.Println()
return nil
}
$ go run ./httptrace -n 2 https://example.com/
request #1 HTTP/2.0 200 OK reused=false remote=104.20.23.154:443
dns 30.2ms
tcp 19.5ms
tls 1108.8ms TLS 1.3, ALPN="h2", resumed=false
got conn 1159.5ms (time to a usable connection)
ttfb 63.5ms (request written -> first byte: server think time + 1 RTT)
download 0.1ms (577 bytes)
total 1223.3ms
Server: cloudflare
Age: 1345
CF-Cache-Status: HIT
Alt-Svc: h3=":443"; ma=86400
request #2 HTTP/2.0 200 OK reused=true remote=104.20.23.154:443
dns -
tcp -
tls -
got conn 0.0ms (time to a usable connection)
ttfb 53.2ms (request written -> first byte: server think time + 1 RTT)
total 53.6ms
Read it like an incident responder:
- The TLS phase took 1,109 ms while TCP took 19.5 ms. One RTT was about 20 ms, so a TLS 1.3 handshake should take about 20 ms too. Something stalled: packet loss during the handshake, a busy TLS terminator, or a retransmitted large certificate flight. Run it again. If it's consistent, capture packets; if it's rare, it's a tail-latency problem worth graphing.
- Request #2 skipped DNS, TCP, and TLS entirely (
reused=true) and finished in 54 ms instead of 1,223 ms. Connection reuse is the cheapest performance fix there is. CF-Cache-Status: HITwithAge: 1345: the CDN answered from a copy about 22 minutes old, so the 63 ms TTFB never touched the origin.ttfb≈ server time + 1 RTT. Subtract the TCP time (≈1 RTT) from TTFB to estimate how long the server or edge spent thinking.
Exercises:
- Point it at your own service and at the same service through your CDN. Which phases move?
- Add
-resolve host:ip(use a customDialContext, as the TCP/IP guide'stcpinfolab does) to test one specific backend or POP. - Print the
Server-Timingheader if present, and add it to your own service:Server-Timing: db;dur=53, render;dur=12. It shows up in browser DevTools too. - Run
-n 20and print p50/p99 of each phase. One slow handshake in 20 is a finding.
Real-world scenario
curl works but the browser fails. That often means the network path is fine
and the issue is browser-enforced: CORS, mixed content, CSP, cookie SameSite,
service worker cache, or a blocked third-party script. The fix starts in the
DevTools Console and Network panel, not in packet capture.
The opposite also happens. DevTools shows a request "stalled" for 300 ms with
no server time. That's time waiting in the browser for a connection slot
(HTTP/1.1 allows about 6 per host) or behind a higher-priority request.
httptrace from the same network will show the server is fast.
Common mistakes
- Starting with packet capture before checking DNS, headers, and DevTools.
- Testing without the same hostname, protocol version, geography, cookies, and headers as affected users.
- Ignoring cache state. "Works in incognito" is evidence, not magic.
- Reading cumulative
curl -wnumbers as per-phase durations. - Measuring once. Latency is a distribution, and incidents hide in the tail.
Check yourself
- In the output above, how would you estimate the RTT, and what does that say about the 1.1 s TLS phase?
- Which tool would you use to prove a CORS failure, and why can't
curlshow it? - What does
--resolvelet you test that changing/etc/hostsmakes harder?
Key Takeaways
- Time the phases first. The slow phase names the layer.
- Pick the vantage point that can see the failure: browser, edge, backend, or wire.
- Connection reuse removes three of the phases entirely.
Across the series: OSI guide, Part 14 maps the same
curl -w phases onto layers. TCP/IP guide, Chapter 34
has the netcheck ladder for "is anything reachable at all?", and
Chapter 32 teaches packet-capture reading with a pcap
decoder written in Go.