Frontend Labs
⋯

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: HIT with Age: 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:

  1. Point it at your own service and at the same service through your CDN. Which phases move?
  2. Add -resolve host:ip (use a custom DialContext, as the TCP/IP guide's tcpinfo lab does) to test one specific backend or POP.
  3. Print the Server-Timing header if present, and add it to your own service: Server-Timing: db;dur=53, render;dur=12. It shows up in browser DevTools too.
  4. Run -n 20 and 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 -w numbers as per-phase durations.
  • Measuring once. Latency is a distribution, and incidents hide in the tail.

Check yourself

  1. In the output above, how would you estimate the RTT, and what does that say about the 1.1 s TLS phase?
  2. Which tool would you use to prove a CORS failure, and why can't curl show it?
  3. What does --resolve let you test that changing /etc/hosts makes 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.

Generated from real-life-guide-v1.md · 8 Oct 2026 16:36