Skip to content

What Happens When You Type a URL: The Complete Production Guide

45 milliseconds in your APM. 3.2 seconds in the browser. The gap is outside your code. A support ticket reports a 3.2-second page load; the APM dashboard reads 45 milliseconds of application time. The rest is DNS, TCP⁠[1], TLS, CDN cache miss, network transit. In this failure mode the application is innocent, the network is invisible to the APM, and the only diagnostic that helps is curl -w walking every layer.

That gap is where slow-page complaints tend to live. Diagnosing it requires walking every layer that fires before the request hits your handler.

The Short Version

An illustrative request on a 20ms-RTT path traverses DNS (~50ms cold), TCP handshake⁠[1] (20ms), TLS (20ms), CDN edge (5ms), your origin (45ms), and the network return (20ms) — ~160ms to first byte, of which your application is 28%. Use curl timing to isolate which layer is slow.

  • Measure every layer with curl's -w timing flags; compare to baseline
  • DNS: dig +trace shows the resolution path; size TTL to your failover needs
  • TLS 1.3 cuts a full handshake from 2 RTTs to 1; resumed TLS 1.2 and False Start are already 1
  • CDN: cache hits skip the origin round trip; set Cache-Control headers correctly

The request path in 160ms

The request lifecycle as a sequence — every layer adds latency you can measure with curl -w:

sequenceDiagram
    participant U as User browser
    participant DNS as Resolver chain
    participant CDN as CDN edge
    participant LB as Load balancer
    participant App as Origin app
    Note over U,App: Illustrative budget (20 ms RTT): ~160 ms first byte
    U->>DNS: lookup api.example.com
    DNS-->>U: IP (50 ms cold, 0-1 ms cached)
    U->>CDN: TCP SYN, SYN-ACK, ACK (20 ms = 1 RTT)
    U->>CDN: TLS 1.3 ClientHello + ServerHello (20 ms = 1 RTT)
    Note over U,CDN: a resumed session can send the request as 0-RTT early data in this first flight
    U->>CDN: HTTP request
    alt Cache HIT
        CDN-->>U: response from edge (5 ms)
    else Cache MISS
        CDN->>LB: forward to origin
        LB->>App: route to pod
        App->>App: handler logic (~45 ms)
        App-->>LB: response
        LB-->>CDN: response
        CDN-->>U: response (cache + serve)
    end

Here's an illustrative budget (20ms RTT) for a request hitting your origin (no CDN cache):

PhaseLatencyWhat's happening
DNS~50msOS → ISP resolver → TLD → authoritative nameserver
TCP handshake~20msSYN, SYN-ACK, ACK (1 RTT)
TLS negotiation~20msTLS 1.3 (1 RTT; 2 RTTs for full TLS 1.2)
CDN edge logic~5msRouting decision, cache miss detection
Your origin~45msLoad balancing + application
Network return~20msResponse delivery
Total~160msOnly 28% is your application (45ms ÷ 160ms)

Your 45ms looks like 160ms to users. The other 115ms is infrastructure you don't see in your APM.

graph LR
    Browser["Browser"] -->|"1. DNS Lookup<br/>~50ms"| DNS["DNS Resolver"]
    DNS -->|"2. IP Address"| Browser
    Browser -->|"3. TCP Handshake<br/>~20ms"| CDN["CDN Edge"]
    Browser -->|"4. TLS Negotiation<br/>~20ms"| CDN
    Browser -->|"5. HTTP Request"| CDN
    CDN -->|"cache miss"| LB["Load Balancer"]
    LB -->|"6. Route to app<br/>~45ms"| App["Application"]
    App -->|"7. Response"| LB
    LB --> CDN
    CDN -->|"8. Compressed<br/>response ~20ms"| Browser

Layer 1: DNS — from cache hierarchy to authoritative answer

The browser checks its cache, the OS checks its cache, then queries your ISP's recursive resolver. The resolver walks the DNS hierarchy: root nameserver → TLD nameserver → authoritative nameserver.⁠[2]

## See the full resolution path with timing
dig +trace api.example.com
 
## Most queries hit resolver cache (skip the full chain)
## New domains run the full chain; repeat lookups cache at TTL

TTL is a trade-off⁠[3]: short TTLs (60s is common for failover records) make changes land fast but multiply queries; a 24-hour TTL locks you in place. Before a migration, lower TTL at least one old TTL ahead, change the record, then raise it back.

## Check current TTL
dig api.example.com | grep -A1 "ANSWER SECTION"
 
## Verify propagation across resolvers
for r in 1.1.1.1 8.8.8.8 9.9.9.9; do
    dig @$r api.example.com +short
done

See the DNS Records Production Guide for DNSSEC, MX, SRV, and failover patterns.


Layer 2: TCP — three-way handshake and connection reuse

The browser and server exchange SYN, SYN-ACK, ACK to establish the connection. This takes 1 RTT (~20ms). The key production lever: connection reuse.⁠[1]

TCP starts slow: the first flight is capped by the initial congestion window — typically 10 segments, about 14 KB⁠[4] — and the window roughly doubles every RTT until it detects loss. A 10 KB response fits in that first flight; a 200 KB response needs about three extra round trips on a fresh connection. A reused connection has a warm window and skips the ramp — unless it sat idle longer than one RTO, because Linux resets the window after idle by default (net.ipv4.tcp_slow_start_after_idle=1⁠[5]). Set it to 0 on hosts that hold long-lived backend pools.

In production:

  • HTTP/1.1 default is Connection: keep-alive — keep it enabled
  • HTTP/2 multiplexes hundreds of concurrent requests over a single connection — eliminating browsers' 6-connections-per-host cap under HTTP/1.1
  • Load balancer to origin should maintain persistent connection pools, not open new connections per request

Enable BBR (modern congestion control) on Linux:

sysctl -w net.ipv4.tcp_congestion_control=bbr
# BBR paces by measured bandwidth and RTT instead of treating packet loss as
# congestion (CUBIC's signal); it gains most where loss isn't from congestion

Layer 3: TLS — 1-RTT negotiation with 1.3

TLS 1.3 (2018) reduced the full handshake from 2 RTTs (TLS 1.2⁠[6]) to 1 RTT; a TLS 1.2 client using False Start⁠[7] already sends data after 1. In TLS 1.3, the client sends its Diffie-Hellman key share in the first message; the server responds with its share, certificate, and finished handshake. After that round trip the client holds the keys and sends HTTP immediately.⁠[8]

## Check if a server supports TLS 1.3
openssl s_client -connect api.example.com:443 -tls1_3 2>&1 | grep "Protocol"
## Output: TLSv1.3 (good) or TLSv1.2 (upgrade your TLS)
 
## Measure TLS handshake time
curl -w "TLS: %{time_appconnect}s - TCP: %{time_connect}s\n" \
     -o /dev/null -s https://api.example.com/health

Enable TLS 1.3 in your load balancer or CDN. If your origin still uses TLS 1.2, upgrade it—modern OpenSSL, nginx, and HAProxy all support it.

See HTTP Protocol Evolution Guide for TLS versions, ciphers, and OCSP stapling.


Layer 4: CDN — cache-first design with headers

The CDN checks its cache first. Cache hit = served from the edge, no origin round trip. Cache miss = origin pull + re-cache.⁠[9]

The lever: Cache-Control headers. These control whether the CDN caches your response and for how long.

// Static assets (images, CSS, JS) — cache for 1 year
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
 
// Shared API responses (product list) — browser keeps 60s, CDN keeps 300s
// s-maxage = shared-cache (CDN) TTL; max-age = browser TTL
w.Header().Set("Cache-Control", "public, max-age=60, s-maxage=300")
 
// User-specific content — never cache at CDN
w.Header().Set("Cache-Control", "private, max-age=300")
 
// Real-time data (prices, inventory) — never cache
w.Header().Set("Cache-Control", "no-store")
 
// Serve stale while fetching fresh (hide origin latency)
w.Header().Set("Cache-Control", "public, max-age=60, stale-while-revalidate=3600")

Debug CDN behavior:

## Check cache status and PoP location
curl -I https://api.example.com/products | grep -E "cache|cf-ray|age"
## X-Cache: HIT ← served from edge
## Age: 45 ← response is 45 seconds old
## CF-Ray: 7abc123-SIN ← Singapore PoP
 
## Force a cache miss to test origin directly
curl -H "Cache-Control: no-cache" -I https://api.example.com/products

Layer 5: Load balancer and application

The load balancer routes requests to healthy backend instances. Use Layer 7 (HTTP) for web traffic — it can route by URL path, headers, and cookies. Use least connections or round robin for balancing; avoid round robin if requests have variable processing time.

Keep connections to your backend persistent (keepalive 100 in nginx). Health checks should ping a lightweight /health endpoint, not your full request path.

The application itself should:

  1. Order middleware by cost: put cheap filters (request ID, logging) before expensive ones (auth, rate limiting)
  2. Respect context cancellation: when the client disconnects, stop processing immediately
  3. Cache aggressively: use Redis or in-memory caches to avoid database round trips
// Context cancellation in action
func GetProductHandler(w http.ResponseWriter, r *http.Request) {
    ctx := r.Context() // cancelled if client disconnects
 
    product, err := db.Get(ctx, r.PathValue("id"))
    if err != nil {
        if errors.Is(err, context.Canceled) {
            return // Client left, stop processing
        }
        http.Error(w, "error", http.StatusInternalServerError)
        return
    }
 
    w.Header().Set("Cache-Control", "public, max-age=300")
    if err := json.NewEncoder(w).Encode(product); err != nil {
        log.Printf("encode product: %v", err) // headers already sent; log only
    }
}

Layer 6: Response path — compression and multiplexing

Compress with Brotli (20% better than gzip) and enable HTTP/2 multiplexing — it sends hundreds of concurrent requests over a single connection instead of opening 6 separate TCP connections per host (browsers' HTTP/1.1 cap). ⁠[10]

## Check if HTTP/2 is active
curl -I --http2 https://api.example.com/products | head -1
## HTTP/2 200 ← active
## HTTP/1.1 200 ← upgrade ALPN config

Enable HTTP/3 (QUIC) at your CDN layer—it tracks packet loss per stream, so a lost packet on one request doesn't stall others. Cloudflare and Fastly have one-click HTTP/3 enablement.


Production checklist — troubleshoot systematically

When someone reports "the site is slow," isolate the layer:

  1. Measure with curl:

    curl -w "dns:%{time_namelookup} tcp:%{time_connect} tls:%{time_appconnect} wait:%{time_starttransfer} total:%{time_total}\n" \
         -o /dev/null -s https://api.example.com/products

    Example: dns:0.042 tcp:0.062 tls:0.084 wait:0.129 total:0.131

    • Read as deltas: tcp - dns = TCP time (0.020s = good), tls - tcp = TLS time (0.022s = good), wait - tls = server time + 1 RTT (0.045s = where to look)
  2. DNS slow (>100ms)? Run dig +trace api.example.com and check TTL. Lower TTL to 60s before migrations.

  3. TCP/TLS slow? Verify TLS 1.3 is enabled: openssl s_client -connect api.example.com:443 -tls1_3 2>&1 | grep Protocol

  4. Server slow (wait > 100ms)? Check CDN cache status: curl -I https://api.example.com/products | grep -i cache. If MISS, origin is the bottleneck—profile with OpenTelemetry.

  5. CDN cache constantly missing? Review Cache-Control headers. Missing headers = no caching. Set max-age=300 for API responses at minimum.


The real win

Back to the ticket this article opened with: the user sees 3.2 seconds, the APM says 45 ms. Suppose the curl checklist prints:

dns: 0.041s, tcp: 3.061s, tls: 3.083s, wait: 3.148s

Read it as deltas. DNS took 41 ms, and wait - tls is 65 ms: one ~20 ms round trip plus the 45 ms the APM reported. The missing ~3 s sits in tcp - dns: the connect itself. Whole seconds plus one normal RTT is the signature of dropped SYNs — the client resends a SYN only when its retransmission timer fires, and that timer starts at one second or more⁠[11], so every lost SYN costs whole seconds. Something on the path to the origin is dropping connection attempts — an overflowing listen queue on the load balancer or a full connection-tracking table on a NAT hop, for example — and the handler never sees them.

No application profile would show it; walking the layers does. That's the value here: when something breaks, you know where to look, what tools to use, and what good numbers are at each layer.


HTTP/3 over QUIC — 0-RTT and connection migration

HTTP/3 runs over QUIC instead of TCP, which collapses the transport and TLS handshakes into a single round trip and eliminates head-of-line blocking at the transport layer. On resumed connections, QUIC can send 0-RTT data — the client attaches application bytes to the very first flight, so the request lands at the edge before the server has even confirmed the resumption secret. On a cross-continental link with 80ms one-way latency (160ms RTT), a 1-RTT QUIC handshake puts the first response byte at ~320ms; 0-RTT puts it at ~160ms for repeat visitors.

The other quiet win is connection migration. A TCP connection is keyed on the four-tuple (src IP, src port, dst IP, dst port), so when a phone roams from Wi-Fi to LTE the OS rotates the source IP and every existing TCP connection dies. The browser has to redo DNS, TCP, and TLS — easily a 200ms stall in the middle of a session. QUIC keys the connection on a Connection ID instead (independent of IP and port), so the same logical connection survives an IP change with no handshake retry — the roaming phone skips the full DNS+TCP+TLS re-handshake and its ~200ms stall entirely.

There are sharp edges. 0-RTT data is replayable by definition — an on-path attacker can capture the encrypted bytes and re-send them later — so HTTP/3 servers must reject 0-RTT for any non-idempotent verb. Browsers default to 0-RTT for GET only, but if your CDN config opts everything in, a POST /api/transfer request becomes replayable. Verify what your edge allows, and lock down state-changing endpoints:

## Confirm HTTP/3 is being negotiated and inspect the QUIC version
curl -v --http3-only https://api.example.com/products 2>&1 | grep -E "QUIC|HTTP/3|alt-svc"
## Look for: * Connected to ... using HTTP/3
## Look for: alt-svc: h3=":443"; ma=86400 (advertises QUIC for 24h)
 
## Force a 0-RTT replay test against a known-idempotent endpoint
curl --http3 --tls-max 1.3 --tls13-ciphers TLS_AES_128_GCM_SHA256 \
     -w "first_byte=%{time_starttransfer}s total=%{time_total}s\n" \
     -o /dev/null -s https://api.example.com/health

Set Alt-Svc: h3=":443"; ma=86400 at the edge to advertise QUIC for 24 hours so subsequent visits skip the HTTP/2 fallback handshake entirely.


Browser hints — preconnect, dns-prefetch, preload, prefetch

The browser doesn't have to wait for the HTML parser to discover that it needs cdn.example.com — you can pay the DNS, TCP, and TLS cost in parallel with the initial document download. The four hints, in order of how much work they do:

HintTriggersCost saved on first byte to that origin
dns-prefetchDNS resolution~20-50ms
preconnectDNS + TCP + TLS~60-100ms
preloadFull request for a known assetRTT + asset transfer
prefetchLow-priority next-page fetchWhole next navigation

preconnect is the highest-value-per-byte tag in your <head>. Use it for the 2-3 third-party origins your critical path depends on (fonts, analytics, the API origin if it differs from the page origin). Beyond ~4 preconnects you start fighting yourself — every warm connection costs sockets and memory in the browser, and the OS has a hard cap on simultaneous connections per process.

// Server-rendered HTML head with prioritized hints
func renderHead(w io.Writer, criticalAPI string) error {
    _, err := fmt.Fprintf(w, `<head>
  <link rel="preconnect" href="https://%s" crossorigin>
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
  <link rel="dns-prefetch" href="https://analytics.example.com">
  <link rel="preload" href="/fonts/inter.woff2" as="font" type="font/woff2" crossorigin>
  <link rel="preload" href="/critical.css" as="style">
  <link rel="modulepreload" href="/app.mjs">
</head>`, criticalAPI)
    return err
}

preload is dangerous if you misuse it — anything you preload competes with the rest of the critical path for bandwidth, so a forgotten <link rel="preload" as="image"> for a hero that was later removed silently steals 200KB from your LCP image. Audit preload usage every release with the Coverage tab in DevTools.

prefetch is the right tool for predicted next-page assets — when the user lands on /products, prefetch /products/1 and /products/2 if your own analytics shows a high click-through rate from the listing to specific product pages. Set the threshold from your own funnel data, not a borrowed number. Browsers schedule prefetches at idle priority, so they don't fight with the current page's critical resources.


Service workers — cache-first offline behavior

A service worker is a script that intercepts every fetch from your origin before it touches the network. Done right, this turns a ~160ms cold load into a ~3ms cached response — and turns a network outage into a working app. Done wrong, you ship a stuck-cache bug that requires users to clear browser data to recover, which is the worst kind of incident because it's invisible to your monitoring.

The pattern that survives production is stale-while-revalidate for the app shell, network-first with timeout for API calls, and cache-only for versioned static assets:

// service-worker.js — production-grade routing
const CACHE_VERSION = 'v2026-04-26';
const SHELL = `shell-${CACHE_VERSION}`;
const RUNTIME = `runtime-${CACHE_VERSION}`;
 
self.addEventListener('install', (e) => {
  e.waitUntil(
    caches.open(SHELL).then((c) =>
      c.addAll(['/', '/offline.html', '/critical.css', '/app.mjs']),
    ),
  );
  self.skipWaiting();
});
 
self.addEventListener('activate', (e) => {
  e.waitUntil(
    caches.keys().then((keys) =>
      Promise.all(
        keys.filter((k) => !k.endsWith(CACHE_VERSION)).map((k) => caches.delete(k)),
      ),
    ),
  );
  self.clients.claim();
});
 
self.addEventListener('fetch', (event) => {
  if (event.request.method !== 'GET') return; // Cache API stores GET only
  const url = new URL(event.request.url);
 
  // API: network-first with 2s timeout, fall back to cache
  if (url.pathname.startsWith('/api/')) {
    event.respondWith(
      Promise.race([
        fetch(event.request).then((r) => {
          const clone = r.clone();
          caches.open(RUNTIME).then((c) => c.put(event.request, clone));
          return r;
        }),
        new Promise((_, reject) => setTimeout(() => reject('timeout'), 2000)),
      ]).catch(() =>
        caches.match(event.request).then((hit) => hit || caches.match('/offline.html')),
      ),
    );
    return;
  }
 
  // Static shell: cache-first
  event.respondWith(caches.match(event.request).then((c) => c || fetch(event.request)));
});

Three production rules. Always version the cache name (v2026-04-26) and delete old versions in activate — otherwise an old service worker keeps serving stale code forever. Always have an unregister kill switch shipped from day one (a /unregister-sw route that calls navigator.serviceWorker.getRegistrations() and unregisters them all) so you can recover from a bad release without asking users to clear data. Never cache HTML longer than a few seconds — HTML is the entry point for new versions of every other asset, so a stale HTML cache strands users on the previous deploy.


Server-Timing — closing the loop on the request budget

Server-Timing is the one HTTP header that closes the gap between APM and browser waterfall. The server emits a header with named phases and durations; the browser surfaces them in DevTools and PerformanceObserver so the browser-side telemetry can attribute its own waterfall back to specific server-side spans. A 200ms wait becomes "60ms DB, 40ms cache, 80ms render, 20ms middleware" without an OpenTelemetry trace context propagation step.

// Emit Server-Timing for every request — durations in milliseconds
type timingsKey struct{}
 
type serverTimings struct {
    start  time.Time
    phases []string
}
 
// record is nil-safe, so handlers still work without the middleware.
func (st *serverTimings) record(name string, d time.Duration) {
    if st == nil {
        return
    }
    st.phases = append(st.phases, fmt.Sprintf("%s;dur=%.1f", name, float64(d.Microseconds())/1000))
}
 
// timingWriter sets Server-Timing just before the headers go out. Setting it
// after next.ServeHTTP returns is too late: the first write freezes headers.
type timingWriter struct {
    http.ResponseWriter
    st          *serverTimings
    wroteHeader bool
}
 
func (tw *timingWriter) WriteHeader(code int) {
    if !tw.wroteHeader {
        tw.wroteHeader = true
        tw.st.record("total", time.Since(tw.st.start))
        tw.Header().Set("Server-Timing", strings.Join(tw.st.phases, ", "))
    }
    tw.ResponseWriter.WriteHeader(code)
}
 
func (tw *timingWriter) Write(b []byte) (int, error) {
    if !tw.wroteHeader {
        tw.WriteHeader(http.StatusOK)
    }
    return tw.ResponseWriter.Write(b)
}
 
func TimingMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        st := &serverTimings{start: time.Now()}
        ctx := context.WithValue(r.Context(), timingsKey{}, st)
        next.ServeHTTP(&timingWriter{ResponseWriter: w, st: st}, r.WithContext(ctx))
    })
}
 
// Inside a handler, record sub-phases before the first write
func ProductHandler(w http.ResponseWriter, r *http.Request) {
    st, _ := r.Context().Value(timingsKey{}).(*serverTimings) // nil without the middleware
 
    t := time.Now()
    product, err := db.Get(r.Context(), "p123")
    st.record(`db;desc="postgres"`, time.Since(t))
    if err != nil {
        http.Error(w, "error", http.StatusInternalServerError)
        return
    }
 
    t = time.Now()
    body, err := json.Marshal(product)
    st.record("render", time.Since(t))
    if err != nil {
        http.Error(w, "error", http.StatusInternalServerError)
        return
    }
 
    w.Header().Set("Content-Type", "application/json")
    if _, err := w.Write(body); err != nil {
        log.Printf("write product: %v", err) // headers already sent; log only
    }
}

The header looks like Server-Timing: db;desc="postgres";dur=42.1, render;dur=3.4, total;dur=51.7. Browsers expose these on the PerformanceResourceTiming entry, so a PerformanceObserver on the client can ship them to RUM with the rest of the navigation timing data:

## Inspect Server-Timing for a given endpoint
curl -sI https://api.example.com/products | grep -i server-timing
## Server-Timing: db;dur=42.1, render;dur=3.4, total;dur=51.7
 
## Correlate with full request budget from the browser side
curl -w "dns:%{time_namelookup} tcp:%{time_connect} tls:%{time_appconnect} ttfb:%{time_starttransfer} total:%{time_total}\n" \
     -H "Accept-Encoding: gzip, br" \
     -o /dev/null -s https://api.example.com/products

Two cautions. Don't put PII in desc= — these headers are visible to any client and will end up in browser telemetry pipelines. And don't use Server-Timing as your primary tracing system — it's a hint for client RUM, not a substitute for distributed tracing. If you have a real OpenTelemetry pipeline, the right pattern is to emit a small handful of high-signal phases (db, cache, render, wait_upstream) and link to the full trace via a separate traceparent header.


Frequently Asked Questions

Why is my website slow even though my server responds in under 50ms?

In the illustrative budget above, application time is 45ms of ~160ms (28%); DNS, TCP, TLS, CDN, and network transit are the other ~115ms. Use the curl timing technique to isolate which layer has the problem.

How does DNS resolution work?

The browser checks its cache, then the OS cache, then queries a recursive resolver. The resolver walks the DNS hierarchy — root, TLD, authoritative nameservers — and returns an IP. Results cache for the TTL duration.

What is TLS 1.3 and why does it matter?

A full TLS 1.3 handshake takes 1 RTT instead of TLS 1.2's 2 (up to 20ms saved on a 20ms-RTT path); resumed TLS 1.2 sessions and False Start clients already take 1. Enable TLS 1.3 at your load balancer or CDN.

How does a CDN reduce latency?

A CDN caches content at geographically distributed edge nodes. On a cache hit, the edge answers directly, skipping the round trip to your origin and its processing time.

Keep Reading

Sources

  1. 1.Transmission Control Protocol (TCP) — IETF, 2022
  2. 2.Domain Names — Implementation and Specification — IETF, 1987
  3. 3.Best practices for Amazon Route 53 DNS — AWS Documentation, 2026
  4. 4.Increasing TCP's Initial Window — IETF, 2013
  5. 5.IP Sysctl — Linux kernel networking documentation — kernel.org, 2026
  6. 6.The Transport Layer Security (TLS) Protocol Version 1.2 — IETF, 2008
  7. 7.Transport Layer Security (TLS) False Start — IETF, 2016
  8. 8.The Transport Layer Security (TLS) Protocol Version 1.3 — IETF, 2018
  9. 9.HTTP Semantics — IETF, 2022
  10. 10.HTTP/2 — IETF, 2022
  11. 11.Computing TCP's Retransmission Timer — IETF, 2011
BackendBytes Engineering Team
BackendBytes

Engineering Team

An independent engineering publication covering distributed systems, databases, and production infrastructure. Every factual claim is cited to a primary source or removed.

Read Next