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.
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.
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-Originfrom every host that sendsServer-Timing. TheserverTimingarray is behind the same cross-origin gate asconnectStartandtransferSize. 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
PerformanceResourceTimingorPerformanceNavigationTimingobject the protocol came from, so the residual, the protocol and the connect cost share one row. Pair this with the buffered observer in collectingnextHopProtocolwith 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 - connectStartobserved 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 againstencodedBodySizedivided 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 onh2whileh3entries 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_msandnextHopProtocol— 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_rttcrossing 3.0 for a sustained window, bucketed bynextHopProtocol.
Reading the residual’s shape
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.
Related
- Measuring Protocol Performance in the Field ↑ — parent topic: the full field-measurement pipeline this residual plugs into
- Collecting nextHopProtocol with Resource Timing — sibling: the buffered observer that supplies the entries measured here
- Mitigating Head-of-Line Blocking in HTTP/2 and HTTP/3 — the transport mechanism behind a large body residual
- QUIC 0-RTT Session Resumption Gotchas — when early data is rejected and the request is sent twice