A Header That Carries More Than Timing

Among HTTP response headers, Server-Timing occupies a unique position. It is the only header that supports free-form values for a specific resource while also making those values accessible through a JavaScript Browser API that operates independently of the Request/Response objects themselves. That combination opens up monitoring possibilities that go well beyond the header’s name suggests.

Resource requests — including the HTML document itself — can be enriched with data during their lifecycle on the server side, and that data can later be inspected from the browser to understand attributes of the resource. The closest comparable mechanism is the Cookie header, but cookies travel with every request and response once set, making them a poor fit for ephemeral, resource-specific data. Server-Timing binds its payload to a single resource response, keeping ambiguous data out of the request stream and avoiding cookie bloat.

Formatting and Exposing the Header

Any server or proxy can attach Server-Timing to the response of any network resource: XHR, fetch, images, HTML, stylesheets, and more. The header requires only a name; description and metric value are optional. Multiple Server-Timing headers on the same response are combined and comma-separated.

Server-Timing: cdn_process;desc=”cach_hit";dur=123

Server-Timing: cdn_process;desc=”cach_hit", server_process; dur=42;

Server-Timing: cdn_cache_hit

Server-Timing: cdn_cache_hit; dur=123

Caveat: For cross-origin resources, Server-Timing values and other potentially sensitive timing data are hidden from consumers unless the response also includes a Timing-Allow-Origin header with your origin or a * value.

Reading Server-Timing Data From the Performance Timeline

The Performance Timeline API exposes entries through performance.getEntries(), performance.getEntriesByType(), and performance.getEntriesByName(), each returning arrays of increasing specificity. The relevant entry types are PerformanceResourceTiming and PerformanceNavigationTiming — the two subtypes tied to network requests. Though the top-level document is fetched as a navigation, its PerformanceNavigationTiming entry includes the same resource-loading fields, so the term “resource” applies uniformly below.

const navResources = performance.getEntriesByType('navigation');
const allOtherResources = performance.getEntriesByType('resource');

Each resource entry carries a serverTiming array whose objects map the header fields into the PerformanceEntryServerTiming interface: name, description, and duration. Taking a concrete example, suppose our data endpoint returns:

Server-Timing: lookup_time; dur=42, db_cache; desc=”hit”;

And this is the only resource on the page:


const dataEndpointEntry = performance.getEntriesByName('resource')[0];

console.log( dataEndpointEntry.serverTiming );

// outputs:
// [
//   { name: “lookup_time”, description: undefined, duration: 42 },
//   { name: “db_cache”, description:”hit”, duration: 0.0 },
// ]

How the Name Misleads

The header’s name naturally anchors thinking to time spans, but the specification is deliberately loose. The duration field carries no intrinsic unit — any double value fits — and the fields have no bindings to specific data types. In practice, you can ship Server-Timing with HTTP response status codes, request IDs, region identifiers, or any free-form datum. Some of that information might duplicate other response headers, but that redundancy is justified: as we’ll see, those headers usually aren’t readable after a request completes.

Monitoring Without a Reference

Web browser APIs provide no mechanism to query past requests and their responses directly. Memory management demands that response data be garbage-collected once references are gone, so to read anything about a request you need a live reference to those objects. Real user monitoring libraries work around this by monkey-patching the networking APIs on the page, capturing request and response details before your code sees them. That forces the monitoring script to load before anything else and gets intricate as patching multiplies across APIs.

That pattern also fails for resources never touched by JavaScript: images, stylesheets, script files, and the HTML document itself have no direct reference you can inspect. The Performance Timeline API sidesteps all of that — it is effectively a registry of every request the page made, carrying metadata for each. Most of that metadata is timing-related, but serverTiming is the hook that lets you attach arbitrary server-side context to any resource in that registry.

The result is a useful inversion: instead of needing a reference to enrich a resource, you enrich the resource at the network layer and query it later through the timeline. Every resource on the page suddenly becomes instrumentable with data that fits your monitoring needs.

Inspecting Asset Responses

Most assets load through HTML elements — img, link, script — leaving developers without direct network API references to those resources. Fetching assets via fetch or XHR for monitoring purposes would gut performance, so it’s not a viable alternative.

Why would you want to inspect these resources? Consider these motivating scenarios:

  • Knowing resource status codes lets teams triage failures — a 404 on missing images is a different issue class than a 500 from the origin server.
  • Assets are frequently delivered through CDNs outside direct team control, and monitoring their health requires observability across that boundary.
  • On-demand asset variation — image resizing, polyfill selection — can fail silently while still returning success status codes, degrading the experience you thought was universally applied.

The last scenario is not hypothetical. In one production case, an image-resizing provider failed for a meaningful share of thumbnail requests, yet responses still returned HTTP 200. The team assumed over 99% of users got optimized images; in practice, over 30% were downloading full-size originals into thumbnail slots.

Attaching Server-Timing metadata to those asset responses makes those failures visible. Suppose the image element and response headers look like this:

<img src="https://www.smashingmagazine.com/user-rsrc/12345?resize=true&height=80&width=80&format=webp" alt="..."/>
Status: 200
…
Server-Timing: status_code; dur=200;, resizing; desc=”failed”; dur=1200; req_id; desc=”zyx4321”

The monitoring code that inspects that image entry can then read what really happened:

const imgPerfEntry = performance.getEntriesByName('/user-rsrc/12345?resize=true&height=80&width=80&format=webp')[0];

// filter/capture entry data as needed
console.log(imgPerfEntry.serverTiming);

// outputs:
// [
//   { name: “status_code”, description: undefined, duration: 200 },
//   { name: “resizing”, description:”failed”, duration: 1200 },
//   { name: “req_id”, description:”zyx4321”, duration: 0.0 },
// ]

For that image, the status code reads 200, so the onerror handler never fired — but the resize step failed after 1.2 seconds of effort. Combined with the request ID, that information allows debugging through backend tooling. With this kind of payload in a RUM provider, conditions like “resize failed” can become aggregatable, proactive alerts rather than silent degradations.

Reading The Timeline Backwards

Most JavaScript-based monitoring works by instrumenting resources before they are requested. Whether that means monkey-patching fetch or attaching onload and onerror handlers, the pattern is the same: the monitoring code has to run first, and anything that happens earlier escapes observation. That assumption is often impractical. Performance budgets push scripts down the page, and critical resources like hero images are fetched as early as possible — well before monitoring is in place.

Browsers, however, keep a buffer of all performance entries automatically. The PerformanceEntry list is not limited to activity that happens after your script runs; it contains the entire history since navigation, subject only to the buffer size limit. That makes retroactive inspection possible without changing request ordering.

Consider a site that needs to confirm product images are delivered successfully. Some images load before the monitoring script executes, and more load later as the user navigates. Using the Performance Timeline API, a single function can handle both cases.

Given image responses structured like this:

Status: 200
…
Server-Timing: status_code; dur=200;, resizing; desc="success"; dur=30; req_id; desc="randomId"

The monitoring logic, deferred until after the critical path, can inspect existing entries and set up observation for future ones:

function monitorImages(perfEntries){
  perfEntries.forEach((perfEntry)=>{
  // monitoring for the performance entries
  
console.log(perfEntry.serverTiming);
})
}

const alreadyLoadedImageEntries = performance.getEntriesByType('resource').filter(({ initiatorType })=> initiatorType === 'img');

monitorImages( alreadyLoadedImageEntries );

const imgObserver = new PerformanceObserver(function(entriesList) {
const newlyLoadedImageEntries = entriesList.getEntriesByType('resource').filter(({ initiatorType })=> initiatorType === 'img');
  monitorImages( newlyLoadedImageEntries );
});
imgObserver.observe({entryTypes: ["resource"]});

This captures data for all images loaded before the script ran and continues tracking new images as they arrive, all without any early instrumentation.

The Last Resource: The HTML Document

If monitoring scripts are loaded via HTML, they cannot observe the delivery of the HTML document that brought them into existence. Server-side logs and traces are the conventional answer, but that decouples the data from RUM and frequently loses the page-instance metadata needed to correlate with other measurements. It also makes it hard to match document responses with, say, subsequent async request failures.

A common workaround is to inject measurement data directly into the HTML body. That, however, can conflict with caching layers that assume fully static documents, and it becomes impossible when intermediaries deeper in the chain need to contribute data. A CDN edge handler cannot be expected to modify the HTML payload handed back from an origin.

Server-Timing headers solve this cleanly because every layer in the response path — CDN, origin, or any proxy — can append its own header, and the browser joins them into a single value on the response. No HTML rewriting required.

Assume a CDN and an origin both process the document. The CDN adds its own response headers:

Status: 200
…
Server-Timing: cdn_status_code; dur=200;, cdn_cache; desc=”expired”; dur=15; cdn_datacenter; desc=”ATL”; cdn_req_id; desc=”zyx321abc789”; cdn_time; dur=120;

The origin, adding its own, might contribute:

Status: 200
…
Server-Timing: origin_status_code; dur=200;, origin_time; dur=30; origin_region; desc=”us-west”; origin_req_id; desc="qwerty321ytrewq789";

The resulting PerformanceEntry for the HTML document can be inspected later by monitoring JavaScript that was loaded well after navigation began:

// as mentioned earlier, the HTML document is a 'navigation' type of Performance Entry
// that has a superset of information related to the resource and the navigation-specific info
const htmlPerfEntry = performance.getEntriesByType('navigation')[0];

// filter/capture entry data as needed
console.log(htmlPerfEntry.serverTiming);

// outputs:
// [
//   { name: “cdn_status_code”, description: undefined, duration: 200 },
//   { name: “cdn_cache”, description:”expired”, duration: 0.0},
//   { name: “cdn_datacenter”, description:”ATL”, duration: 0.0 },
//   { name: “cdn_req_id”, description:”zyx321abc789”, duration: 0.0 },
//   { name: “cdn_time”, description: undefined, duration: 120 },
//   { name: “origin_status_code”, description: undefined, duration: 200 },
//   { name: “origin_time”, description: undefined, duration: 30 },
//   { name: “origin_region”, description:”us-west”, duration: 0.0 },
//   { name: “origin_req_id”, description:”qwerty321ytrewq789”, duration: 0.0 },
// ]

From this single entry, monitoring code can aggregate where document processing happened, read status codes returned by different servers, and capture request identifiers for correlating with server logs. The durations also make latency segmentation possible. Subtracting the cdn_time duration from the already available Time-To-First-Byte value, and further breaking out the origin_time, separates user network latency from CDN-to-origin latency — valuable detail for a delivery path as critical as the HTML response itself.

Adding A Service Worker Layer

Service workers can act as a proxy between the site, the browser, and the network, giving them the ability to read and modify requests and responses. The practical combination with Server-Timing relies on an important detail: the Server-Timing header and its resulting PerformanceEntry are calculated after service worker handling takes place. That means a service worker can append its own Server-Timing headers to responses, and the browser will faithfully record them in the timeline.

This opens up several measurements that would otherwise be difficult to capture on the main thread:

  • Whether the response was served from the service worker cache.
  • Whether it was served while offline.
  • Which service worker strategy was used for the request type.
  • The service worker version, useful for verifying invalidation assumptions.
  • Values copied from other response headers into a Server-Timing header when headers cannot be changed upstream (common with CDN providers).
  • How long a resource had been sitting in the service worker cache.

The basic setup starts with registration logic on the site:

if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/service-worker.js').then(function (registration) {
registration.update(); // immediately start using this sw
 });
}

In the service worker script itself, simple request/response proxying can attach the header:

const CACHE_NAME = 'sw-cached-files-v1';

self.addEventListener('fetch', function (event) {
  event.respondWith(
    // check to see if this request is cached
    caches.match(event.request)
      .then(function (response) {

        // Cache hit - return response
        if (response) {
          const updatedHeaders = new Headers(response.headers);
          updatedHeaders.append('Server-Timing', 'sw_cache; desc="hit";');
          const updatedResponse = new Response(response.body, {
            ...response,
            headers: updatedHeaders
          });
          return updatedResponse;
        }
        
        return fetch(event.request).then(function (response) {

            // depending on the scope where we load our service worker,
            // we might need to filter our responses to only process our
            // first-party requests/responses
            // Regex match on the event.request.url hostname should

            const updatedHeaders = new Headers(response.headers);
            updatedHeaders.append('Server-Timing', `status_code;desc=${response.status};, sw_cache; desc="miss";`)

            const modifiableResponse = new Response(response.body, {
              ...response,
              headers: updatedHeaders
            });

            // only cache known good state responses
            if (!response || response.status !== 200 || response.type !== 'basic' || response.headers.get('Content-Type').includes('text/html')) {
              return modifiableResponse;
            }

            const responseToCache = modifiableResponse.clone();

            caches.open(CACHE_NAME).then(function (cache) {
              cache.put(event.request, responseToCache);
            });

            return modifiableResponse;
          }
        );
      })
  );
});

Once in place, every response processed by the service worker carries its Server-Timing data, readable through the Performance Timeline API exactly as in the earlier examples. If a service worker is already handling requests for other reasons, adding one header at two points in the handler yields status codes for all requests, cache-hit ratios, and a measure of how often requests actually pass through the worker.

Why Not Use The Service Worker Exclusively?

Given that service workers can touch every request and response, it is fair to ask why Server-Timing is needed at all. The issue is timing.

RUM clients typically have narrow windows in which request data can be enriched: when the response occurs, and when the PerformanceEntry is observed. A service worker introduces multiple race conditions against those windows. The worker may not be activated yet when a request fires, or a request may bypass it entirely. Even when a worker does capture data, getting it to the main thread requires asynchronous postMessage() communication, which may not finish before the RUM client has already collected and sent its data.

Server-Timing avoids this entirely. A RUM client watching the Performance Timeline API sees the data synchronously as part of the PerformanceEntry, with no separate messaging step and no dependency on whether a service worker is active.

The practical division of labor is to use Server-Timing as the primary channel for enriching request/response data, and to treat the service worker as a supplementary source of context — particularly when Server-Timing is unsupported or when the service worker can add details no upstream server could know. In those cases, custom events or metrics may be more appropriate than attempts to enrich original request data, given the unavoidable race conditions.

Practical Caveats For Server-Timing

While Server-Timing is a uniquely capable tool, its current implementation carries several caveats worth planning around.

Browser Coverage

Safari currently omits Server-Timing data from the Performance Timeline API, though the data still appears in DevTools. For browser-based monitoring this isn't a blocker: these tools rarely capture every browser. With roughly 70–75% global support, the signal is strong enough to indicate system health. Since Server-Timing often remains the sole reliable way to fetch these metrics, the coverage gap is acceptable. If Safari support is strictly necessary, a cookie-based fallback could be explored, but it needs heavy testing to avoid performance regressions.

Header Size And Naming

Adding headers does add weight to responses. As a rule of thumb, don't worry until your Server-Timing header exceeds roughly 500 bytes; if in doubt, measure the impact of varying lengths. Additionally, appending multiple Server-Timing headers on one response risks duplicate metric names, which browsers expose in the serverTiming array of the PerformanceEntry. Use specific, namespaced names, or build a helper that updates existing entries instead of blindly appending new ones. If duplicates are unavoidable, document a consistent ordering convention.

Cached Response Awareness

Remember that cached responses still carry the Server-Timing values captured when the resource was originally generated. If you don't want to report server timing for responses served from cache, you'll need to detect whether a request hit the network. On the PerformanceEntry, look for indicators like entry.transferSize > 0, entry.decodedBodySize > 0, or entry.duration > 40 — or set a timestamp in the header itself to compare against.

Final Thoughts

The Server-Timing header is typically associated purely with latency measurement, but it's capable of much more. Its freeform data lets you attach arbitrary metadata to any resource and retrieve it without referencing the network API that made the request. This opens up retroactive inspection of resources of all types, and even allows attaching data to the HTML document itself. Combined with service workers, you can enrich responses with service worker context or map metadata from uncontrolled server responses into Server-Timing for cleaner access.

Server-Timing deserves broader adoption, but it isn't a universal solution. I've relied on it in instrumentation projects where resource data was otherwise inaccessible and where pinpointing latency sources was critical. If the header doesn't fit your use case, don't force it — the goal here is simply to offer a fresh perspective on Server-Timing as a tool worth reaching for, even when you're not measuring time.

Resources