Guide

Saying "the page is slow" is not a diagnosis. Browser tools split a request into phases. Server headers and traces split the server's share into its parts. Together they turn "it's slow" into "the recommendations call is slow".

Concept

Before you start: TTFB (time to first byte) is the time from sending a request to receiving its first byte. It contains one round trip plus all server time. A span is one timed step in a trace, with a start time and a duration.

Walking through it: the browser waterfall

Open Chrome DevTools, go to the Network tab, reload, and click a request's Timing tab. Each phase maps onto the earlier pages:

DevTools phaseWhat it isLarge when
Queueing / StalledWaiting for a connection slot, or behind higher-priority requestsToo many requests on HTTP/1.1, or a busy main thread
DNS LookupName resolutionCold cache, slow resolver, long CNAME chains
Initial connectionTCP handshake (or QUIC)Far from the server, no CDN
SSLTLS handshakeSame, plus missing session resumption
Request sentUploading the requestLarge bodies or cookies
Waiting for server response (TTFB)One round trip plus all server timeSlow backend, cache miss at the CDN
Content DownloadReceiving the bodyLarge or uncompressed responses, slow links

A reused connection shows no DNS, Initial connection or SSL bars. That is the visual sign that keep-alive is working. A staircase of requests, each waiting on the one before, points to dependency chains. HTML discovers CSS, which discovers a font. Preload hints or inlining can break those chains.

Walking through it: Server-Timing

To see the server's share inside TTFB, send a Server-Timing header. DevTools shows it in the same Timing tab:

HTTP
Server-Timing: db;dur=8.1, recs;dur=24.7, render;dur=11.9, total;dur=59.6

Each entry is a named duration in milliseconds. It is cheap to add, and it tells you which part of the server to look at first.

Walking through it: distributed traces

For the full picture, use distributed tracing (OpenTelemetry, exported to Jaeger, Tempo, Honeycomb or Datadog). Each hop creates a span. A traceparent header carries the trace ID across service boundaries.

The trace for our product page would show the load balancer span, the app span, and child spans for the Redis calls, the SQL query and the recommendations call. They sit on a timeline. Gaps between spans matter as much as the spans. An unexplained 15 ms gap is often a queue, a lock or a GC pause. See observability.

Inside the data center: the server's 60 ms

Here is a realistic breakdown of the 60 ms of server time for the product page:

Load balancer (L7 routing, maybe TLS re-encrypt)        1 ms
Framework: parse request, middleware, auth check        4 ms
Redis GET session + GET product:42  (2 × ~0.5 ms)       1 ms
Postgres: price + inventory query (index scan, warm)    8 ms
Recommendations service call (HTTP, same region)       25 ms
Template rendering + JSON serialization                12 ms
Compression (Brotli level 5, 120 KB → 30 KB)            4 ms
Response write (syscalls, kernel buffers)              ~1 ms
Unaccounted (GC pauses, queueing, scheduler)            4 ms
                                                       ------
                                                       60 ms

Each line maps to an earlier page. The Redis lookups are fast because they are one in-region round trip to data held in RAM. The Postgres query is fast because the B-tree's upper levels and the hot rows are in memory. A cold read that misses to SSD adds around 0.1 ms per page. A sequential scan of a large table can add seconds.

The "unaccounted" line is where GC pauses and thread-pool queueing live, and it is usually what grows at p99. The biggest item here is a downstream call. That is typical in microservice systems. If downstream calls run one after another instead of concurrently, their latencies add up.

Tip

Look for the largest single line first. Here, the recommendations call is 25 of 60 ms, so that is the place to start.

Common mistakes

  • Reading the waterfall without checking for reuse. A missing DNS bar is good news, not an error.
  • Trusting one trace. Compare several slow traces. Look for what they share.
  • Ignoring gaps. Unexplained time between spans usually points to queueing, locks or GC.

Check yourself

  • Why does a reused connection show no DNS or SSL bars in DevTools? Those phases were done on an earlier request and the connection is still open.
  • In the breakdown above, which line should you attack first, and why? The recommendations call, at 25 ms, because it is the largest single item.

Key takeaways

  • The browser waterfall splits a request into phases. Missing handshake bars mean connection reuse is working.
  • Server-Timing headers put named server durations into the browser's Timing tab.
  • Distributed traces show each hop as a span. Gaps between spans point to queues, locks and GC.
  • In the example, the 60 ms server time is mostly one downstream call, so that call is where to start.

Further learning

Next, use these tools to debug a slow endpoint hop by hop, in debugging a slow endpoint.

MDN: Server-Timing documents the header and its fields.