The Server-Timing response header lets a server attach named measurements to a response. Browser developer tools and performance APIs can then associate those measurements with the resource request. It is useful when the network waterfall shows a long wait but cannot explain whether backend work involved a database call, rendering, or another measured operation. The header supplies server-selected observations, not an independent browser measurement of those operations.

The duration syntax is db;dur=42, render;dur=81, with durations expressed in milliseconds. A bare db=42 is not the duration syntax. Each metric has a name and can include duration and description parameters. Use stable names that readers can interpret, and derive values from actual instrumentation in production. A description can clarify the measurement, but should not contain raw queries, identifiers, or internal details that the response should not disclose.

Match the number to its interval

Server measurements do not necessarily add up to the browser’s request duration. They may cover nested operations, overlapping work, or only part of the handling path. Adding database and rendering time is valid only when their definitions justify that addition. Network transport, queuing outside the measured interval, caching, and client-side processing can explain differences without either clock being wrong.

The header also does not create a distributed trace. It names measurements on one response without automatically supplying parent-child relationships, propagation, or collector storage. Use it to expose a small set of useful request measurements, then follow trace or log correlation when a deeper explanation is needed. When responses pass through a cache or proxy, inspect the delivered header and establish which component produced the numbers.

When it breaks

For cross-origin resources, reading a response header in JavaScript and receiving detailed Resource Timing data are separate permissions. CORS allows the response to be read, and Access-Control-Expose-Headers can expose Server-Timing to response.headers. Timing-Allow-Origin controls the corresponding detailed timing exposure. A successful header read therefore does not prove that PerformanceResourceTiming.serverTiming will contain the same measurements.

Another failure is confusing a parser demonstration with measured performance. Sending a fixed duration establishes what the browser parses, not how long the handler ran. Likewise, an empty timing array does not prove that the server did no work. First check origin permissions and the actual wire header, then inspect measurement placement. Keep public names and descriptions appropriate for their audience; an operationally useful timing hint should not accidentally reveal sensitive backend structure.

Check it quickly

Save this as server-timing.py and run python3 server-timing.py --serve. Python 3.14.4 was used for execution. It starts two loopback HTTP origins on separate temporary ports and prints the page URL to open. The page fetches two resources and displays the header values alongside the browser’s parsed timing entries. Press Control-C to close both servers. Without --serve, the script checks response headers and exits without running a browser.

import json
import sys
from http.server import BaseHTTPRequestHandler, HTTPServer
from threading import Event, Thread
from urllib.request import Request, urlopen

timing = "db;dur=42, render;dur=81"


class API(BaseHTTPRequestHandler):
    def log_message(self, *args):
        pass

    def do_GET(self):
        body = b"synthetic timing fixture\n"
        self.send_response(200)
        self.send_header("Content-Type", "text/plain; charset=utf-8")
        self.send_header("Content-Length", str(len(body)))
        self.send_header("Cache-Control", "no-store")
        self.send_header("Access-Control-Allow-Origin", app_origin)
        self.send_header("Access-Control-Expose-Headers", "Server-Timing")
        self.send_header("Server-Timing", timing)
        if self.path.split("?", 1)[0] == "/allow":
            self.send_header("Timing-Allow-Origin", app_origin)
        self.end_headers()
        self.wfile.write(body)


class App(BaseHTTPRequestHandler):
    def log_message(self, *args):
        pass

    def do_GET(self):
        body = ("""<!doctype html><meta charset="utf-8"><title>Server-Timing lab</title>
<h1>Cross-origin Server-Timing</h1><pre id="result">Running...</pre>
<script>
const api = """ + json.dumps(api_origin) + """;
async function run() {
  performance.clearResourceTimings();
  const results = {};
  for (const path of ['deny', 'allow']) {
    const url = api + '/' + path;
    const response = await fetch(url, {cache:'no-store'});
    await response.text();
    await new Promise(resolve => requestAnimationFrame(resolve));
    const entry = performance.getEntriesByName(url).at(-1);
    results[path] = {header:response.headers.get('Server-Timing'),
      timing:entry.serverTiming.map(item => ({name:item.name, duration:item.duration}))};
  }
  window.labResult = results;
  document.getElementById('result').textContent = JSON.stringify(results, null, 2);
}
run().catch(error => {window.labError=String(error); document.getElementById('result').textContent=String(error);});
</script>""").encode()
        self.send_response(200)
        self.send_header("Content-Type", "text/html; charset=utf-8")
        self.send_header("Content-Length", str(len(body)))
        self.end_headers()
        self.wfile.write(body)


api = HTTPServer(("127.0.0.1", 0), API)
app = HTTPServer(("127.0.0.1", 0), App)
api_origin = f"http://127.0.0.1:{api.server_port}"
app_origin = f"http://127.0.0.1:{app.server_port}"
threads = [Thread(target=server.serve_forever, daemon=True) for server in (api, app)]
for thread in threads:
    thread.start()
try:
    for path in ("deny", "allow"):
        with urlopen(Request(api_origin + "/" + path, headers={"Origin": app_origin}), timeout=5) as response:
            assert response.headers["Server-Timing"] == timing
            assert response.headers["Access-Control-Allow-Origin"] == app_origin
            assert response.headers["Access-Control-Expose-Headers"] == "Server-Timing"
            assert response.headers.get("Timing-Allow-Origin") == (app_origin if path == "allow" else None)
            response.read()
    print(json.dumps({"app_url": app_origin, "api_url": api_origin,
                      "wire_checks": "PASS", "durations_are_synthetic": True}), flush=True)
    if "--serve" in sys.argv:
        Event().wait()
except KeyboardInterrupt:
    pass
finally:
    for server in (api, app):
        server.shutdown()
        server.server_close()
    for thread in threads:
        thread.join(timeout=5)

Both responses allow the page’s origin through CORS, expose Server-Timing, and carry the same synthetic database and rendering durations. Only /allow adds Timing-Allow-Origin. In Chrome 152, both displayed header strings were db;dur=42, render;dur=81; the denied timing array was empty, while the allowed array contained db with duration 42 and render with duration 81. This browser result was checked separately from the Python wire assertions.

The fixed numbers intentionally test syntax and visibility rather than elapsed backend time. This local example does not verify another browser, production proxies, cached responses, or a real database measurement. To deploy the idea, measure well-defined intervals, emit the header before the response headers are committed, and repeat the check through the actual delivery path. Explain the timing names and overlap rules so readers can interpret the numbers without inventing an additive breakdown the server never promised.

Sources

  • W3C Server Timing, header parameters and PerformanceServerTiming exposure, checked 2026-09-13.
  • MDN Server-Timing, duration syntax, browser use, and disclosure considerations, checked 2026-09-13.
  • MDN Timing-Allow-Origin, permission for detailed cross-origin resource timing, checked 2026-09-13.