When you check a website’s response time, “847ms” doesn’t tell you much. Is it slow DNS? Network latency? The server itself? You need a breakdown.

At upsonar.io we show exactly where the time goes. Here’s how we do it with Go’s net/http/httptrace package.

The problem

start := time.Now()
resp, _ := http.Get("https://example.com")
fmt.Println(time.Since(start)) // 847ms

847ms. But why? Could be anything.

The solution

httptrace hooks into every phase of an HTTP request. You attach callbacks, and they fire as each phase completes:

trace := &httptrace.ClientTrace{
    DNSStart: func(_ httptrace.DNSStartInfo) {
        dnsStart = time.Now()
    },
    DNSDone: func(_ httptrace.DNSDoneInfo) {
        fmt.Printf("DNS: %v\n", time.Since(dnsStart))
    },
    // same pattern for TCP, TLS, etc.
}

req = req.WithContext(httptrace.WithClientTrace(req.Context(), trace))

What each metric means

DNS Lookup — resolving domain to IP. Slow (100ms+) means DNS server issues or cold cache.

TCP Connect — the three-way handshake. Mostly about physical distance to the server.

TLS Handshake — negotiating encryption. Usually 50-150ms. Over 300ms means something’s off — slow server or problematic certificate chain.

TTFB — time to first byte. Includes DNS + TCP + TLS + server processing. This is the standard metric for measuring response speed.

Transfer — downloading the response body.

Working example

package main

import (
    "crypto/tls"
    "fmt"
    "io"
    "net/http"
    "net/http/httptrace"
    "os"
    "time"
)

func main() {
    url := "https://httpbin.org/delay/2"
    if len(os.Args) > 1 {
        url = os.Args[1]
    }

    var dnsStart, tcpStart, tlsStart time.Time
    totalStart := time.Now()

    trace := &httptrace.ClientTrace{
        DNSStart:          func(_ httptrace.DNSStartInfo) { dnsStart = time.Now() },
        DNSDone:           func(_ httptrace.DNSDoneInfo) { fmt.Printf("DNS:  %v\n", time.Since(dnsStart)) },
        ConnectStart:      func(_, _ string) { tcpStart = time.Now() },
        ConnectDone:       func(_, _ string, _ error) { fmt.Printf("TCP:  %v\n", time.Since(tcpStart)) },
        TLSHandshakeStart: func() { tlsStart = time.Now() },
        TLSHandshakeDone:  func(_ tls.ConnectionState, _ error) { fmt.Printf("TLS:  %v\n", time.Since(tlsStart)) },
        GotFirstResponseByte: func() { fmt.Printf("TTFB: %v\n", time.Since(totalStart)) },
    }

    req, _ := http.NewRequest("GET", url, nil)
    req = req.WithContext(httptrace.WithClientTrace(req.Context(), trace))

    client := &http.Client{Transport: &http.Transport{DisableKeepAlives: true}}
    resp, err := client.Do(req)
    if err != nil {
        fmt.Println("Error:", err)
        return
    }
    defer resp.Body.Close()

    transferStart := time.Now()
    io.ReadAll(resp.Body)
    fmt.Printf("Transfer: %v\n", time.Since(transferStart))
    fmt.Printf("Total: %v\n", time.Since(totalStart))
}
go run main.go https://httpbin.org/delay/2

Output:

DNS:  148.00525ms
TCP:  142.56925ms
TLS:  289.685ms
TTFB: 2.956339583s
Transfer: 72.208µs
Total: 2.95670575s

TTFB is everything until the first byte arrives. Transfer is downloading the body — here it’s tiny. Total = TTFB + Transfer.

With a larger response, Transfer becomes significant:

go run main.go https://proof.ovh.net/files/10Mb.dat
DNS:  3.621333ms
TCP:  54.364208ms
TLS:  116.879041ms
TTFB: 286.073291ms
Transfer: 14.221007833s
Total: 14.507351083s

Same TTFB formula, but now Transfer dominates — 14 seconds to download 10MB.

Watch out for connection reuse

Go’s http.Client reuses connections by default. Second request to the same host — DNS, TCP, TLS all show 0ms.

For accurate measurements, disable keep-alives:

client := &http.Client{
    Transport: &http.Transport{
        DisableKeepAlives: true,
    },
}

What slow numbers tell you

  • DNS > 100ms → Try a faster DNS provider (Cloudflare, Google)
  • TCP > 100ms → Server is far from users, consider a CDN
  • TLS > 200ms → Check certificate chain, enable session resumption
  • TTFB > 500ms → Backend problem: slow database, cold starts, heavy processing
  • Transfer high → Large response, enable gzip/brotli compression

The 200ms rule

Users notice delays over 200ms. If your TTFB alone exceeds that, the page will feel slow — no matter how optimized your frontend is.


We built this timing breakdown into the upsonar.io diagnostics tool — but the code above works if you want to run your own.


This is part of the Building a Monitoring Tool in Go series: