Reading Server-Timing Headers for Protocol Stalls

Your p95 document TTFB is 372 ms, your application tracing insists the handler finished in 94 ms, and no phase in the waterfall accounts for the missing quarter second — this guide turns Server-Timing into a residual you can compute on every request, and reads that residual’s shape to name the protocol stall producing it.

Root cause: the waiting phase is four things reported as one number

The browser gives you exactly one number for the server: responseStart - requestStart. requestStart is stamped when the user agent begins sending the request bytes on an already-established connection; responseStart is stamped when the first byte of the response — for HTTP/2 and HTTP/3, the first byte of the response HEADERS frame — is available to the fetch. Four different things live between those stamps, and only one of them is your code.

The first is the request’s flight to the server and the first response byte’s flight back: at minimum one round trip, and not attributable to anyone. The second is server work — edge routing, cache lookup, origin queueing, handler compute. The third is the edge-to-origin hop, which is invisible from the browser and invisible from the origin, so it is the layer that most often disappears from a trace entirely. The fourth is transport stall: a lost request packet that has to be retransmitted, a QUIC handshake that took an extra round trip because the server sent a Retry, an early-data rejection that forces the request to be sent a second time, or a QPACK-blocked stream whose HEADERS frame has physically arrived but cannot be decoded yet.

Server-Timing is the only standard channel for the second and third of those. It is deliberately weak: a metric carries a duration, never a timestamp, because the server clock and the browser’s monotonic time origin share no reference point. That weakness is also the diagnostic. Since the spans cannot be positioned, they can only be summed — and the part of the measured wait that the sum does not cover is a quantity nobody on the server is claiming:

head residual = (responseStart - requestStart) - Σ serverTiming[i].duration

On a healthy connection the head residual should land near one round trip. When it lands at eight, the extra time did not happen in your handler and did not happen in your edge; it happened on the wire or inside the protocol stack, and the residual is the only field instrument that isolates it.

A p95 navigation: 372 ms of waiting, 156 ms of it explained Two aligned bars on one time scale. The upper bar is the browser's measured wait of 372 milliseconds from requestStart to responseStart. The lower bar tiles the four Server-Timing spans against it: edge 18 ms, edge-to-origin leg 8 ms, origin queue 36 ms and application 94 ms, totalling 156 ms, leaving a 216 ms residual that no server span accounts for against a healthy floor of one 28 ms round trip. A p95 navigation: 372 ms of waiting, 156 ms of it explained Browser: responseStart - requestStart 372 ms of measured wait, attributed to nobody Server: the four Server-Timing spans, summed 216 ms that no server span claims edge;dur=18.0 ol;dur=8.0 q;dur=36.0 app;dur=94.0 head residual Healthy residual About one round trip: 28 ms here. Observed residual 216 ms, or 7.7 round trips.

What the header actually allows

Server-Timing is a comma-separated list of metrics. Each metric is a token name followed by any number of semicolon-delimited parameters, of which only two are defined: dur, a number of milliseconds that may be fractional, and desc, a token or quoted string. Names and parameter values that contain a space, comma or semicolon must be quoted. Unknown parameters are ignored rather than rejected, multiple Server-Timing header fields on one response concatenate into one list, and a metric with no dur surfaces as a PerformanceServerTiming whose duration is 0 — which is why a cache-state marker must be read from description, never from duration.

HTTP/3 200
content-type: text/html; charset=utf-8
timing-allow-origin: https://www.example.com
server-timing: edge;dur=18.0, ol;dur=8.0;desc="edge to origin leg", q;dur=36.0;desc="origin accept queue", app;dur=94.0, cache;desc=MISS

Four rules make that header usable as an instrument rather than a curiosity. The spans must be disjoint — each owns a slice of wall clock time no other span owns — because the residual is a subtraction and nested spans subtract twice. The set must be complete down to the last hop you control, which is why ol exists: without it the edge-to-origin round trip lands in the residual and looks like a client-side transport stall. Names must be short, because dur values change on every response and never compress away in the QPACK or HPACK dynamic table, so each metric costs roughly its literal length per response. And the header must be accompanied by Timing-Allow-Origin, since serverTiming sits behind exactly the same cross-origin gate as the phase timestamps described in measuring protocol performance in the field.

Where each Server-Timing span is measured, and where the residual accumulates Three lifelines: browser, edge point of presence and origin. The request travels browser to edge to origin, the origin's queue and application spans are drawn as activation bars on its lifeline, and the response returns edge to browser. Each leg between browser and edge costs 108 milliseconds against a 28 millisecond healthy round trip, and those two legs are the whole 216 millisecond residual. Where each span is measured, and where the 216 ms actually went Browser Edge PoP Origin 1 request headers sent (requestStart) 108 ms on the wire, healthy RTT is 28 ms 2 edge dispatches to origin edge;dur=18.0 and ol;dur=8.0 q;dur=36.0 app;dur=94.0 3 first response byte leaves the origin 4 responseStart recorded 108 ms back, same leg, same loss Every span above is measured on a server clock, so none of them can be placed on the browser timeline. Only their sum subtracts: 372 - 156 = 216 ms of residual against a 28 ms floor.

Minimal reproduction

Two files and one traffic-control command reproduce a residual that no application profiler can see. The origin instruments itself honestly; the network is what lies.

// origin.mjs — an HTTP/2 origin that reports its own wait truthfully.
import { createSecureServer } from 'node:http2';
import { readFileSync } from 'node:fs';

createSecureServer({
  key: readFileSync('key.pem'), cert: readFileSync('cert.pem')
}, (req, res) => {
  const t0 = performance.now();
  setTimeout(() => {
    const app = performance.now() - t0;          // real handler wall clock, nothing else
    res.writeHead(200, {
      'content-type': 'text/html; charset=utf-8',
      // TAO is not optional: without it serverTiming is [] on a cross-origin entry and
      // the residual silently becomes the entire waiting phase on every single request.
      'timing-allow-origin': '*',
      // Disjoint spans only. `q` is measured from socket accept to handler entry, so it
      // does NOT overlap `app` — an overlapping pair would be subtracted twice and would
      // drive the residual negative, hiding the very stall this page is about.
      'server-timing': `q;dur=0.4, app;dur=${app.toFixed(1)}`
    });
    res.end('<!doctype html><title>residual</title>');
  }, 94);                                        // stand-in for the 94 ms handler
}).listen(8443);
# Inject 4% loss on the loopback path. A lost request packet is retransmitted after the
# QUIC or TCP probe timeout, which lands entirely between requestStart and responseStart
# and is therefore invisible to the origin's own timers — exactly the stall we want.
sudo tc qdisc add dev lo root netem loss 4% delay 14ms

# Confirm the header survives the hop chain before trusting any client-side number.
# A CDN rule bound only to the h3 listener is the usual reason it does not.
curl -sSI --http3 https://localhost:8443/ | grep -iE 'server-timing|timing-allow-origin'

Load the page a few dozen times with the collector below. app;dur stays pinned at 94 ms across every sample while the measured wait scatters between 130 ms and 600 ms. All of that scatter is residual, and none of it is reachable from server-side tracing.

Deterministic fix protocol

Each step is independently verifiable. Steps 1 to 3 make the residual correct; steps 4 to 6 make it computable; steps 7 and 8 make it actionable.

  • [ ] 1. Send Timing-Allow-Origin from every host that sends Server-Timing. The serverTiming array is behind the same cross-origin gate as connectStart and transferSize. Until the header matches your page origin (or is *), every cross-origin entry reports an empty array, the sum is zero, and the residual equals the whole waiting phase — so every request looks catastrophically stalled and none of them are.

  • [ ] 2. Emit disjoint spans that tile the server’s wait. One span per layer, each owning a slice of wall clock time no other span owns. The standard slip is an outer total;dur= alongside the inner spans it already contains; that double-counts and pushes the residual negative. If you want a nested total for humans, namespace it (x-total) and exclude the prefix from the sum.

  • [ ] 3. Merge the origin’s spans at the edge and emit the leg explicitly. The edge is the only place that can measure the edge-to-origin round trip, because it knows both its own fetch duration and — by parsing the origin’s header — how much of that the origin claims. The difference is the leg.

    export default {
      async fetch(request, env, ctx) {
        const t0 = Date.now();
        const upstream = await fetch(request);
        const fetchMs = Date.now() - t0;             // edge-side view of the origin round trip
    
        // Sum only the origin's own spans. Subtracting them from the edge's measurement
        // leaves the edge<->origin network leg, which neither end can see alone — and which
        // otherwise lands in the client-side residual and gets misread as a client stall.
        const upstreamSpans = (upstream.headers.get('server-timing') || '')
          .split(',')
          .map(s => parseFloat((s.match(/;\s*dur\s*=\s*([\d.]+)/i) || [])[1]))
          .filter(Number.isFinite)
          .reduce((a, b) => a + b, 0);
    
        const out = new Response(upstream.body, upstream);
        // append, not set: the origin's spans must survive so the client sum stays complete.
        out.headers.append('Server-Timing',
          `edge;dur=${(Date.now() - t0 - fetchMs).toFixed(1)}, ` +
          `ol;dur=${Math.max(0, fetchMs - upstreamSpans).toFixed(1)};desc="edge to origin leg"`);
        out.headers.set('Timing-Allow-Origin', 'https://www.example.com');
        return out;
      }
    };
  • [ ] 4. Compute the head residual on the client, per entry. Read it off the same PerformanceResourceTiming or PerformanceNavigationTiming object the protocol came from, so the residual, the protocol and the connect cost share one row. Pair this with the buffered observer in collecting nextHopProtocol with Resource Timing so the earliest requests are not missing from every session.

    function residuals(entry, rttBaseline) {
      // serverTiming is [] (never undefined) when the header is absent OR TAO-gated, so an
      // empty array must be recorded as "unknown", not as a zero-cost server.
      const spans = entry.serverTiming || [];
      if (spans.length === 0) return null;
    
      const claimed = spans.reduce((sum, m) => sum + m.duration, 0);
      const wait    = entry.responseStart - entry.requestStart;
    
      // Queueing for a connection or a stream slot happens BEFORE requestStart, so it is not
      // in `wait` at all — keep it separate rather than folding it into the residual, or a
      // connection-limit problem will be misdiagnosed as a transport stall.
      return {
        queued_ms:   Math.round(entry.requestStart - entry.fetchStart),
        connect_ms:  Math.round(entry.connectEnd - entry.connectStart),
        claimed_ms:  Math.round(claimed),
        head_res_ms: Math.round(wait - claimed),
        // Normalising by the session's own RTT is what makes the number comparable between a
        // 12 ms fibre session and a 180 ms satellite one; a raw millisecond threshold would
        // flag every slow network as a stall and every fast one as healthy.
        head_res_rtt: +((wait - claimed) / rttBaseline).toFixed(1),
        protocol:    entry.nextHopProtocol || 'unknown'
      };
    }
  • [ ] 5. Derive the RTT baseline from the session, not from a constant. Use the smallest non-zero connectEnd - connectStart observed in the page load; when every connection was reused, fall back to the smallest head residual across all entries, which on a healthy connection is one round trip. Cache the value for the session and beacon it alongside the rows.

  • [ ] 6. Compute the body residual separately. A first-byte stall and a transfer stall have different causes and different fixes, so never merge them. Take responseEnd - responseStart, and compare it against encodedBodySize divided by the session’s observed goodput (the best bytes-per-millisecond ratio seen on any entry over 30 KB). A body residual above roughly 2× the modelled transfer time on h2 while h3 entries in the same session are clean is the field signature of transport head-of-line blocking.

  • [ ] 7. Branch on the residual’s shape before touching any configuration. The tree below turns three fields you already collect — head_res_rtt, connect_ms and nextHopProtocol — into a named cause. Walk it top to bottom; the first branch that matches is the one to investigate.

  • [ ] 8. Alert on the residual percentile, not on TTFB. TTFB moves every time a handler moves, so a TTFB alert fires on deploys and pages the wrong team. The residual is insensitive to handler cost by construction: it only moves when the transport, the edge-to-origin leg, or an uninstrumented server layer moves. Alert on p95 head_res_rtt crossing 3.0 for a sustained window, bucketed by nextHopProtocol.

Reading the residual’s shape

From head residual to a named protocol stall A three-question decision tree. First, is the head residual more than three round trips; if not the wait is server-bound. Second, was the connection new; if so the stall is in the handshake. Third, is the response HTTP/3 with large headers; if so it is QPACK encoder-stream blocking. Otherwise the cause is request-packet loss, an early-data rejection, or an uninstrumented origin pool wait. From one head residual to one named stall 1 Residual over 3 x RTT? wait minus the sum of dur No: the wait is server-bound Profile app;dur and q;dur, not QUIC. no yes 2 Was the connection new? connectEnd - connectStart over 0 Yes: a handshake stall QUIC Retry, address validation, TLS. yes no 3 h3 with big headers? long set-cookie, wide vary Yes: QPACK stream blocking Decode waits on the encoder stream. yes no No: loss on the request, a 0-RTT rejection, or an uninstrumented pool wait Check retransmits and add a pool-acquire span at the origin before blaming the protocol.

The QPACK branch is the one most teams have never considered. HTTP/3 compresses header fields against a dynamic table whose insertions travel on a separate unidirectional encoder stream. If a response’s HEADERS frame references an entry the decoder has not received yet — because the encoder-stream packet carrying it was lost — that request stream is blocked: the bytes are in the browser’s socket buffer, but responseStart cannot be stamped until the insertion arrives. The result is a residual of roughly one extra round trip on h3 only, concentrated on responses with large, high-entropy headers. SETTINGS_QPACK_BLOCKED_STREAMS caps how many streams may be blocked at once; setting it to 0 forbids the encoder from making such references at all, trading a few percent of header compression for the guarantee that no header decode ever waits. On a resumed connection with a small connect_ms, an early-data rejection produces a similar one-round-trip residual — the 0-RTT resumption gotchas guide covers when a server will reject.

If instead queued_ms is large and the residual is small, nothing is stalled in the protocol at all: the request was waiting for a connection or a stream slot before it was ever sent, which is a different diagnosis covered in diagnosing request queueing and stalled time.

Before and after

Measured on a content site doing 12 000 pageviews an hour, over 7 days each side. The fix was: emit the ol leg at the edge, set SETTINGS_QPACK_BLOCKED_STREAMS to 0, and cut a 4.1 KB set-cookie down to 380 bytes.

Metric Before After How it was measured
Document TTFB p75 318 ms 176 ms RUM, responseStart - requestStart
Document TTFB p95 904 ms 402 ms RUM, same field
Head residual p75 194 ms 41 ms wait minus Σ dur
Head residual p95 712 ms 96 ms wait minus Σ dur
Head residual p95, in RTTs 25.4× 3.4× residual ÷ 28 ms session baseline
Navigations over 3× RTT residual 21.6 % 3.1 % share of sampled navigations
app;dur p75 94.0 ms 93.6 ms unchanged — the win was not server work
Response header bytes, p50 4 730 1 040 edge access log

The last two rows are the point. A flat app;dur alongside a collapsing residual is what proves the change was transport; if app;dur had moved too, the comparison would be confounded and the protocol claim unsupportable.

FAQ

Why is entry.serverTiming empty when the header is definitely on the response?

For a cross-origin response the array is gated by the same Timing-Allow-Origin check as the phase timestamps, so DevTools will happily show you a header the API refuses to expose. Two further causes catch people out: a Server-Timing sent on a 103 interim response is not surfaced on the navigation entry in any engine, and a Server-Timing sent as an HTTP trailer is never surfaced at all. Send it as a real response header on the final response, from the same host that sends the TAO header.

Can I place Server-Timing spans on the browser waterfall?

No, and any tool that draws them at absolute positions is inventing the offsets. A metric carries a duration and never a timestamp, precisely because the server clock and the browser’s monotonic time origin share no reference point and are not synchronised. Durations sum and subtract; they do not locate. That constraint is what makes the residual the right abstraction — it is the only quantity the arithmetic actually supports.

My residual came out negative. What went wrong?

Almost always double counting: an outer span, typically an edge or gateway total, that already contains the origin spans you also summed. Emit spans that tile disjoint slices of wall clock time, or namespace the nested ones and filter that prefix out of the sum. A persistent negative of only one or two milliseconds is a different bug — either millisecond-granularity clocks rounding four spans down, or a server that starts its timer when the first request byte arrives rather than the last, which makes it claim time the browser attributes to the request’s flight.