Home  /  Web Performance  /  Server-Timing Header
Web Performance

Server-Timing Header: Measuring CDN and Origin Performance in Production

By FlyFish NWA Editorial — Cloud Infrastructure Specialist September 13, 2026 9 min read

Real user monitoring tells you that a page is slow. The Server-Timing header tells you why — and whether the delay came from the CDN edge, the origin, or a downstream dependency. For teams running a CDN in front of dynamic origins, it is the most lightweight, standards-based way to get end-to-end visibility without adding a separate beacon.

Unlike synthetic tests or server logs alone, Server-Timing propagates timing data all the way to the browser's Performance Timeline. That means you can correlate Time to First Byte (TTFB), cache state, and backend latency for every real navigation and subresource, segmented by geography, device, and cache status. When configured correctly, it adds less than 50 bytes of overhead and works with any CDN that can append or preserve response headers.

TL;DR: Add Server-Timing: cdn-cache; desc="HIT", edge; dur=12, origin; dur=87 at the edge and origin. Expose it via Timing-Allow-Origin: * for cross-origin RUM, and read it with performance.getEntriesByType('navigation')[0].serverTiming.

What Server-Timing Actually Does

Server-Timing is defined in the W3C Server Timing specification. It allows a server (or multiple servers in a chain) to communicate performance metrics to the user agent as a structured HTTP header. Each metric has a name, an optional duration in milliseconds, and an optional description.

The header is not just for human debugging in DevTools. Browsers parse it into the Resource Timing and Navigation Timing APIs, making it available to JavaScript. That is the critical difference from custom headers like X-Cache or X-Origin-Time: those are invisible to performance.getEntries() unless you manually fetch and parse them, which you cannot do for navigation requests.

A typical production chain looks like this: the origin generates its own timings (database, template render, API calls), the CDN edge adds its own (cache lookup, edge compute, origin fetch time), and the browser aggregates both. Because the header can appear multiple times and values are comma-separated, each hop can append without overwriting the previous hop — if your CDN is configured to preserve origin headers.

For a deeper primer on how CDNs handle requests, see our CDN fundamentals guide and CDN architecture overview, which cover edge PoPs, shielding, and origin fetch flow.

Anatomy of the Header

The syntax is intentionally simple:

Performance Comparison: Edge vs Origin Processing
Metric Edge Processing Origin Processing Improvement
Latency (p50)8–15 ms120–300 ms15–20x faster
Cache Hit Ratio85–95%N/AReduces origin load
TLS Handshake1–5 ms (nearby PoP)50–150 ms10–30x faster
Bandwidth Cost$0.01–0.04/GB$0.05–0.12/GB3–5x cheaper
Availability99.99% (multi-PoP)99.9% (single DC)10x fewer outages
Server-Timing: metric, metric;dur=123.4, metric;desc="description";dur=12.3

Examples you will see in production:

// Cache hit served entirely from edge
Server-Timing: cdn-cache; desc="HIT"; dur=0.8, edge; dur=11.2

// Cache miss with origin fetch
Server-Timing: cdn-cache; desc="MISS", edge; dur=8.4, origin; dur=142.7, db; dur=89.3

// With edge compute (e.g., image optimization)
Server-Timing: cdn-cache; desc="HIT", edge; dur=6.1, img-optimize; dur=22.4

Multiple headers are equivalent to a single comma-separated header. The spec requires that dur values be interpreted as milliseconds, and browsers will expose them with sub-millisecond precision if provided. Keep durations to one decimal place to avoid unnecessary header bloat.

Designing Metrics: CDN vs. Origin vs. Application

The most common mistake is emitting a single total duration. That tells you nothing about where time was spent. Instead, define a small, consistent taxonomy across edge and origin so you can slice RUM data effectively.

Metric Name Source What It Measures Typical Budget
cdn-cache CDN Edge Cache state via desc (HIT, MISS, STALE, BYPASS) —
edge CDN Edge Edge processing time: TLS, WAF, cache lookup, edge function < 15 ms
origin CDN Edge Time from origin fetch start to first byte from origin < 150 ms
db Origin Database / cache query time at origin < 50 ms
app Origin Application render / API handler time < 100 ms
img-optimize CDN Edge On-the-fly image transformation < 30 ms

With this taxonomy, you can answer production questions instantly: Is p95 TTFB high because of origin slowness or edge cold starts? Are MISSes correlated with higher origin durations in a specific region? Did a deployment increase db time?

Keep the set to 4–6 metrics. Every additional metric adds header bytes and cardinality for your analytics. If you need richer traces, use distributed tracing (OpenTelemetry) server-side and expose only the summary via Server-Timing. For cost implications of cache efficiency, see our CDN cost optimization guide — improving HIT ratio directly reduces origin egress and compute.

CDN Server-Timing Request Flow Diagram showing browser to CDN edge to origin with timing annotations. BROWSER NavigationTiming serverTiming API REQUEST CDN EDGE (PoP) cdn-cache; edge; dur=12 HIT / MISS / STALE Adds Server-Timing ORIGIN FETCH on MISS ORIGIN origin; dur=87, db; dur=42 App + DB timing RESPONSE + Server-Timing: cdn-cache, edge, origin, db Preserve & append — do not overwrite
Figure 1: Edge appends its timings to origin timings. Browser aggregates both via the Performance Timeline.

Implementing at the Origin

Start at the origin so the CDN has something to preserve. The pattern is: start a high-resolution timer at request ingress, stop it before sending the response, and emit the header.

Node.js / Express Example

import { performance } from 'perf_hooks';
import express from 'express';

const app = express();

app.use((req, res, next) => {
  const start = performance.now();
  let dbTime = 0;

  // Example: wrap DB call to capture duration
  req.measureDb = async (fn) => {
    const s = performance.now();
    const r = await fn();
    dbTime += performance.now() - s;
    return r;
  };

  res.on('finish', () => {});
  const originalWriteHead = res.writeHead;
  res.writeHead = function(...args) {
    const appDur = (performance.now() - start - dbTime).toFixed(1);
    const dbDur = dbTime.toFixed(1);
    const total = (performance.now() - start).toFixed(1);
    res.setHeader('Server-Timing',
      `origin; dur=${total}, db; dur=${dbDur}, app; dur=${appDur}`);
    return originalWriteHead.apply(res, args);
  };
  next();
});

Nginx Example

# nginx.conf — requires $request_time and upstream timing
add_header Server-Timing "origin; dur=${request_time_ms}, db; dur=${upstream_db_time}ms" always;
# If using FastCGI / proxy, capture upstream header time
proxy_pass http://app;
add_header Timing-Allow-Origin "*" always;

Key rule: always set Timing-Allow-Origin if your CDN and origin are on different origins from the page (which they usually are for assets). Without it, performance.getEntries()[i].serverTiming will be an empty array for cross-origin resources, even though the header is visible in DevTools.

Implementing at the CDN Edge

The edge must do two things: preserve the origin's Server-Timing and append its own. How you do this depends on your provider.

With most CDNs, you use an edge worker, VCL, or rule to read the origin header and re-emit it. For example, in Cloudflare Workers, Fastly VCL, or AWS CloudFront Functions, you would:

// Cloudflare Worker (edge) — append without overwriting
export default {
  async fetch(request, env, ctx) {
    const start = Date.now();
    const res = await fetch(request);
    const edgeDur = Date.now() - start;
    const cacheState = res.headers.get('CF-Cache-Status') || res.cf?.cacheStatus || 'UNKNOWN';

    const originTiming = res.headers.get('Server-Timing');
    const edgeTiming = `cdn-cache; desc="${cacheState}"; dur=0, edge; dur=${edgeDur}`;
    const combined = originTiming ? `${originTiming}, ${edgeTiming}` : edgeTiming;

    res.headers.set('Server-Timing', combined);
    res.headers.set('Timing-Allow-Origin', '*');
    return res;
  }
}
# Fastly VCL — preserve origin Server-Timing
sub vcl_deliver {
  if (fastly_info.state ~ "HIT") {
    add http.Server-Timing = "cdn-cache; desc=HIT; dur=0";
  } else {
    add http.Server-Timing = "cdn-cache; desc=MISS";
  }
  add http.Server-Timing = "edge; dur=" + time.elapsed.msec;
  set resp.http.Timing-Allow-Origin = "*";
}

Test that your CDN does not strip origin headers. Some default configurations drop unknown headers — you must explicitly allow Server-Timing to pass through. Also ensure you use add / append semantics rather than set, which would overwrite origin values. For a comparison of edge capabilities, see CDN benefits for performance and security.

Reading Metrics in the Browser

Once headers are flowing, you can read them in two places:

1. DevTools Network Panel: Open Network, select a request, look at Headers → Response Headers and Timing. Chrome and Firefox surface Server-Timing durations directly in the waterfall.

2. JavaScript (RUM): This is where the real value lies. Use the PerformanceObserver API to capture timings for navigations and resources without blocking the main thread.

// RUM snippet — capture Server-Timing for navigation + resources
function reportServerTiming(entry) {
  if (!entry.serverTiming || entry.serverTiming.length === 0) return;
  const metrics = Object.fromEntries(
    entry.serverTiming.map(({name, duration, description}) => 
      [name, { duration, description }])
  );
  // Example: send to analytics
  navigator.sendBeacon('/analytics/timing', JSON.stringify({
    url: entry.name,
    cache: metrics['cdn-cache']?.description || 'unknown',
    edge: metrics.edge?.duration,
    origin: metrics.origin?.duration,
    db: metrics.db?.duration,
    ttfb: entry.responseStart - entry.startTime
  }));
}

const observer = new PerformanceObserver((list) => {
  list.getEntries().forEach(reportServerTiming);
});
observer.observe({ type: 'navigation', buffered: true });
observer.observe({ type: 'resource', buffered: true });

Correlate serverTiming with responseStart and transferSize to distinguish slow origin from large payloads. Segment by cdn-cache description to quantify the HIT ratio's impact on Core Web Vitals — particularly LCP and INP for dynamic pages. You can also log these to your CDN's analytics pipeline if you prefer server-side aggregation.

Production Best Practices and Pitfalls

Keep headers small. Browsers and CDNs typically limit total header size to 8–16KB. Server-Timing should stay under 200 bytes. Use abbreviated names and one decimal place. Do not emit per-SQL-query timings — aggregate them.

Sample, don't flood. For high-traffic origins, compute detailed db and app timings for only 10–20% of requests or when duration exceeds a threshold. You can still emit edge and cdn-cache for every request, as those are cheap at the edge.

Handle caching correctly. If a response is cached, the origin's Server-Timing from the original fetch will be cached too. Ensure your edge either caches the header as-is for HITs or, better, generates fresh edge timings on every HIT while preserving the stale origin timing with a desc indicating it is cached. Avoid caching a MISS timing as if it were current.

Security hygiene. Never expose internal hostnames, shard IDs, or stack traces in desc. Use generic descriptors. If you need detailed diagnostics, gate them behind an internal header like X-Debug-Timing that is only added for requests with a valid debug cookie or internal IP.

Validate with curl. Before deploying RUM, verify headers end-to-end:

curl -i -H "Pragma: no-cache" https://www.example.com/api/products | grep -i server-timing
# Expect: Server-Timing: cdn-cache; desc="MISS", edge; dur=9.2, origin; dur=134.5, db; dur=78.1

curl -i https://www.example.com/api/products | grep -i server-timing
# Expect HIT on second request: cdn-cache; desc="HIT"

When done right, Server-Timing becomes your cheapest observability tool. It bridges the gap between CDN analytics and APM without extra infrastructure, and it works for every user, not just synthetic monitors. Pair it with proper cache control and you will quickly identify whether to optimize the edge, the origin, or the database — and prove the impact of each change with real data.

Frequently Asked Questions

Does exposing Server-Timing headers leak sensitive information?

It can if you expose internal hostnames, detailed database timings, or cache keys. Stick to abstract metric names like cdn-cache, origin, db and durations. Avoid desc values with infrastructure details in production unless restricted to authenticated monitoring. Audit headers with curl from an unauthenticated session before launch.

Can I see Server-Timing in all browsers?

All modern browsers expose Server-Timing via the Resource Timing API and DevTools. For cross-origin requests, you must send Timing-Allow-Origin to allow JavaScript access. Without it, you can still see headers in DevTools but not via performance.getEntries(). Safari, Chrome, Edge, and Firefox all support it since 2020.

What is the performance overhead of Server-Timing?

Negligible — typically under 1ms to generate and a few dozen bytes of header. The header is text-only and does not block rendering. The benefit of production visibility far outweighs the cost, even at high request volumes. Keep the header under 200 bytes and limit to 4–6 metrics to avoid header bloat.

How is Server-Timing different from X-Cache or Age headers?

X-Cache and Age tell you only hit/miss and cache age. Server-Timing provides structured, high-resolution durations for multiple phases — edge processing, origin fetch, database, and image optimization — all parseable by browser APIs and RUM. You can correlate it with Navigation Timing to measure true user impact.

References & Standards: RFC 7234 · OWASP CDN Security