Server-Timing Header for TTFB Attribution: A 2026 RUM Guide
Break down Time to First Byte into named backend phases with the Server-Timing header. Includes syntax, CORS gotchas, Node/Workers/nginx examples, and RUM integration.
The Server-Timing header is an HTTP response header that lets your backend break down TTFB into named, measurable phases (database, cache lookup, template render, edge wait) so you can attribute slow Time to First Byte to a specific subsystem instead of guessing. Browsers expose those metrics through the PerformanceServerTiming interface, which means you can stream the same numbers your APM dashboard sees into your RUM beacon and join field data to backend traces. In 2026, every modern browser supports it. Honestly, the only thing stopping most teams is one missing CORS header.
Server-Timing exposes server-side phase durations (DB, cache, render, edge) to browsers via the Server-Timing response header and the PerformanceServerTiming DOM API.
Without Timing-Allow-Origin, cross-origin responses surface metric names but zero durations. That's the silent failure mode that traps most first-time integrators.
The header syntax is a comma-separated list of name;dur=ms;desc="label" entries. No fancy parser needed on either side.
Field data wins: pair Server-Timing with the web-vitals library to attribute p75 TTFB regressions to a specific backend phase per device class.
Chrome, Edge, Firefox, and Safari 16.4+ all expose PerformanceServerTiming; the long Safari gap is finally closed.
Never emit raw SQL, secrets, or PII in desc values. These ship to every user.
What is the Server-Timing header used for?
Server-Timing is used to communicate one or more server-side performance metrics to the browser, where they show up in DevTools' Network panel and become available to JavaScript through the Navigation Timing and Resource Timing entries. In practice, I use it for two things: surfacing a phase breakdown of TTFB to engineers reading a single request in DevTools, and joining those phase durations to RUM data so I can answer "which backend stage is dragging p75 TTFB on Android in São Paulo?" without opening Datadog.
The synthetic-only crowd loves to argue about which phase "should" dominate TTFB. CrUX and your own field data settle the argument in about an hour. With Server-Timing on every response, p75 TTFB stops being a single mystery number and becomes a stacked bar (DNS, TCP, TLS from Navigation Timing, plus your real server phases from Server-Timing). That's the breakdown a frontend engineer can actually act on.
It's not a replacement for distributed tracing. It complements it. Tracing tells you what's happening across services; Server-Timing tells the browser, and your RUM, what slice of TTFB to blame for the LCP regression a user just experienced. Without it, the LCP triage starts with "is it the network or the server?" and ends three meetings later.
Server-Timing header syntax explained
The header is a comma-separated list. Each entry is a metric name plus optional dur (duration in milliseconds, fractional allowed) and desc (a quoted human-readable label). Order doesn't matter. The duration unit is fixed at milliseconds (never seconds, never microseconds), and the parser is lenient about whitespace.
You can send multiple Server-Timing headers in a single response; the browser concatenates them as if they were one comma-separated list. That matters for middleware pipelines where each layer wants to append its own phase without parsing what came before. The HTTP/2 and HTTP/3 hpack/qpack encoders deduplicate well, so the on-wire cost is negligible even with eight or ten phases.
Names should be short identifiers (db, cache, tpl, edge). Chrome DevTools truncates long names, and you'll want stable, low-cardinality strings for grouping in your RUM. The desc field is the right place for a free-form note: the specific cache region, the template name, the DB replica. Just remember it's user-visible.
Attributing TTFB to real backend phases
TTFB isn't one number. From the user's perspective it's the sum of: DNS lookup, TCP handshake (or 0-RTT QUIC resume), TLS negotiation, request upload, server processing, and the first byte of the response landing in the kernel buffer. The Navigation Timing API gives you DNS, TCP, and TLS for free. Server-Timing fills in the server-processing slice, which on most apps is where the regressions hide.
A pragmatic phase set I ship by default:
edge: time spent at the CDN edge before origin fetch (or total edge time for cached responses)
origin: round-trip from edge to origin, if applicable
auth: session or token validation
db: total database time (sum of queries, not wall clock if parallel)
cache: Redis/Memcached lookups
render: template or SSR render
total: the full request handler wall clock, useful as a sanity check
For SSR React or Next.js, I split render into react-render and data-fetch because they're often confused. If your 103 Early Hints are working, log a hints phase too. It makes the value of the optimization legible to anyone reading a single trace.
How to read Server-Timing from JavaScript
The metrics live on the PerformanceNavigationTiming and PerformanceResourceTiming entries, under a serverTiming array. Each entry is a PerformanceServerTiming object with name, duration, and description. This is the same code path whether you're instrumenting a navigation, an XHR, or a fetch.
// Navigation TTFB attribution from the document itself
const nav = performance.getEntriesByType('navigation')[0];
const phases = Object.fromEntries(
nav.serverTiming.map(t => [t.name, t.duration])
);
console.log('TTFB phase breakdown', phases);
// { edge: 37.1, db: 12.4, render: 4.2, total: 56.8 }
// Streaming attribution for every same-origin resource
const obs = new PerformanceObserver(list => {
for (const entry of list.getEntries()) {
if (!entry.serverTiming?.length) continue;
for (const t of entry.serverTiming) {
reportToRum({
url: entry.name,
phase: t.name,
durationMs: t.duration,
label: t.description,
});
}
}
});
obs.observe({ type: 'resource', buffered: true });
Three details I've been bitten by. First, duration is a JS Number already in milliseconds, so no conversion is needed. Second, if the server emitted the name with no dur, duration is 0; don't treat that as "zero time," treat it as "unreported." Third, buffered: true on the observer is critical, because without it you miss every resource that loaded before your script registered.
CORS and Timing-Allow-Origin: the silent failure
This is the single most common reason teams give up on Server-Timing. I hit this exact bug shipping a RUM rollout last year: your origin emits perfect db;dur=12.4 values, you fetch from cdn.example.com on a page served from app.example.com, and every duration in JS reads as zero. The header is there in DevTools; the numbers are not. The fix is one header on the responses you want to expose:
Timing-Allow-Origin: https://app.example.com
# or, for everyone:
Timing-Allow-Origin: *
Same-origin responses don't need this, including the document itself for first-party pages. CDNs vary in how easily you can add the header to third-party-served assets; Cloudflare and Fastly both expose it as a one-line config, while AWS CloudFront requires a Lambda@Edge or a response-headers policy. The W3C spec for this behavior is short and worth a read: the W3C Server Timing specification defines the exact filtering rules.
The header is trivial to emit from any backend. The interesting part is wiring the measurement so phases reflect reality without becoming its own performance problem.
For Fastly VCL, the equivalent is set resp.http.Server-Timing = ... in vcl_deliver; their compute platform exposes the same fetch-style API as Workers. Vercel automatically emits a Server-Timing header on every response with edge and (for SSR) render phases, so you don't need to do anything to get the basics. The MDN Server-Timing reference is the canonical browser-side documentation, and Chrome's DevTools Network timing reference covers how the panel renders the header.
Integrating Server-Timing into your RUM pipeline
Server-Timing's real payoff is RUM. A Server-Timing value in DevTools is interesting; a Server-Timing percentile distribution sliced by device class, country, and route is actionable. The web-vitals library's attribution build already includes a navigationEntry on the TTFB metric, and navigationEntry.serverTiming gives you the phase array directly.
Once you have this, the analysis stops being "TTFB is 800ms at p75 on Android" and starts being "TTFB is 800ms at p75 on Android, of which 540ms is db and 180ms is edge wait, with render staying flat at 30ms." That's a different conversation. It's also the kind of breakdown that justifies the HTTP/3 migration or a database read-replica investment with actual numbers instead of vibes.
Browser support and quirks in 2026
As of mid-2026, PerformanceServerTiming is supported in every major browser. The long-standing Safari gap was closed in Safari 16.4 (March 2023), and current Safari, Chrome, Edge, and Firefox all expose the full name, duration, and description on same-origin and CORS-permitted cross-origin responses.
Browser
DevTools display
PerformanceServerTiming API
Cross-origin via TAO
Chrome / Edge
Yes (Network → Timing tab)
Yes
Yes
Firefox
Yes (since 110)
Yes
Yes
Safari 16.4+
Yes
Yes
Yes
Safari ≤16.3
No
No
n/a
Two quirks worth knowing. Chrome rounds duration values to multiples of the high-resolution time clamp, so sub-millisecond phases may all read as 0.1 or quantize visibly. That's fine for percentile aggregation, less fine for chasing 200μs regressions. Safari is stricter about header parsing; an unquoted desc with spaces will be silently dropped where Chrome accepts it. Always quote.
For older Safari users, fall back gracefully: nav.serverTiming will simply be undefined. Don't feature-detect Server-Timing at the header level. Detect it at the entry level ('serverTiming' in entry) because that's what actually gates your code path.
Pitfalls, security, and what not to expose
Server-Timing values ship to every client. Treat them as public. Three things I've seen leak in real production traffic:
Raw SQL. A team I worked with put the query string into desc for debuggability. It worked great until a security review noticed customer email addresses sitting in WHERE clauses on every product page response. Use opaque labels.
Internal hostnames and IPs.desc="db-replica-7.internal.use1.prod.acme.com" tells an attacker a great deal about your topology. Use logical labels (desc="replica").
Timing oracle for auth. If your auth phase reports 0.4ms for "user not found" and 4.1ms for "bad password," you've built a username enumeration side channel directly into HTTP. Either coarsen the value (round to 1ms) or omit the phase on failed auth.
And one more thing the synthetic-testing crowd misses: Server-Timing is a field-data instrument. Lighthouse won't surface it. A Lighthouse-only workflow can ship a beautifully optimized site whose p75 TTFB is wrecked by a single slow database replica in one region, and you'll never know without RUM-side Server-Timing aggregation. Field first, synthetic second, always.
Frequently Asked Questions
Does Server-Timing affect page performance?
Negligibly. A typical header with 5–10 phases adds ~150–300 bytes per response, which HTTP/2 and HTTP/3 hpack/qpack compress away on subsequent same-connection responses. The bigger cost is the measurement itself: wrapping every phase with performance.now() is essentially free, but instrumenting every database query in a hot loop can add up. Time at the boundary, not per row.
Can I see Server-Timing in Chrome DevTools?
Yes. Open DevTools → Network → click any request → Timing tab. Server-Timing entries appear in a "Server Timing" section below the standard waterfall. Hovering shows the desc field. They're also visible on the request's Headers tab as the raw Server-Timing response header, which is useful for confirming the value the browser parsed.
Does Safari support Server-Timing?
Yes, since Safari 16.4 (March 2023). Both DevTools display and the PerformanceServerTiming JavaScript API are fully supported in current Safari on macOS and iOS. Older Safari versions silently ignore the header; feature-detect with 'serverTiming' in performance.getEntriesByType('navigation')[0].
Is Server-Timing the same as the Navigation Timing API?
No, they're complementary. Navigation Timing reports browser-measured phases (DNS, TCP, TLS, request, response) for the document load. Server-Timing carries server-measured phases (DB, cache, render) that the browser cannot observe directly. Combined, they give you a complete TTFB breakdown from URL parse to first byte.
Why are all my Server-Timing durations zero in JavaScript?
The response is cross-origin and missing Timing-Allow-Origin. Browsers strip durations and descriptions from cross-origin Server-Timing entries unless the server opts in with this header. Add Timing-Allow-Origin: * (or a specific origin) on the responses you want to expose. Same-origin responses don't need it.
Client Hints (Sec-CH-DPR, Sec-CH-Width, Critical-CH) let your CDN pick byte-perfect image sizes in 2026. Practical setup, worker code, and real LCP wins from a Chrome-heavy audit.
Learn how RFC 9211's Cache-Status header exposes hits, misses, forwards, and coalesced requests across your CDN chain. Includes vendor mapping and TTFB attribution patterns.
Tag any image, price, or button with elementtiming and read its real paint timestamp from PerformanceObserver. Practical RUM setup, cross-origin gotchas, and the new Container Timing trial.