Server-Timing header (tested in Chromium 153)
Server-Timing lets your server attach its own timings (database, cache, app logic) to a response, and the browser exposes them to JavaScript through the Performance API. We sent several variants from a Node server to Chromium 153 and recorded what serverTiming contained.
1. The results (navigation response)
Read with performance.getEntriesByType('navigation')[0].serverTiming, which gives a list of objects with name, duration (milliseconds, a number) and description.
| Header sent | serverTiming in Chromium 153 (name, duration, description) |
|---|---|
Server-Timing: db;dur=53.2 | db, 53.2, "" |
Server-Timing: db;dur=53, app;dur=47.2;desc="App logic" | db, 53, "" and app, 47.2, "App logic" |
Server-Timing: cache;desc="HIT" (no dur) | cache, 0, "HIT" |
Two header lines: db;dur=10 and app;dur=20 | db, 10, "" and app, 20, "" |
Server-Timing: db;dur=abc, ;dur=5, ok;dur=7 | db, 0, "" only |
Server-Timing: a;desc=no quotes, b;dur=1;dur=2 | a, 0, "no" and b, 1, "" |
Things worth knowing from this:
- A missing
durbecomes 0 and a missingdescbecomes an empty string. You cannot tell "0 ms" from "no duration" in JavaScript. - Two separate Server-Timing header lines were combined; both metrics showed up, in order.
- A non-numeric
dur=abcdid not drop the metric; it kept the name with duration 0. - An entry with no name (
;dur=5) did not just get skipped: everything after it was lost too, including the validok;dur=7. One bad entry can hide the rest of the header. - An unquoted description with a space (
desc=no quotes) was cut at the space, giving "no". Quote descriptions. - When
durappeared twice, the first value (1) was used.
2. Cross-origin subresources need Timing-Allow-Origin
Page on http://localhost:8973, resource on http://127.0.0.1:8974 (different origin), header db;dur=53, app;dur=47.2;desc="App logic". Read with performance.getEntriesByName(url)[0].serverTiming.
| Request | Response headers | serverTiming |
|---|---|---|
| Same-origin fetch | (none extra) | both metrics |
| Cross-origin fetch | Access-Control-Allow-Origin: *, no Timing-Allow-Origin | empty list |
| Cross-origin fetch | ACAO * + Timing-Allow-Origin: * | both metrics |
| Cross-origin fetch | ACAO * + Timing-Allow-Origin: http://localhost:8973 (exact origin) | both metrics |
| Cross-origin fetch | ACAO * + Timing-Allow-Origin: http://example.com | empty list |
Cross-origin <img> (no crossorigin attribute) | no Timing-Allow-Origin | empty list |
Cross-origin <img> (no crossorigin attribute) | Timing-Allow-Origin: * | empty list |
Cross-origin fetch(url, {mode:'no-cors'}) | Timing-Allow-Origin: * | empty list |
CORS alone was not enough, and a wrong origin in Timing-Allow-Origin counted as absent. The surprise: for a non-CORS cross-origin image and a no-cors fetch, Timing-Allow-Origin: * was not enough in Chromium 153; serverTiming stayed empty. In our test, the cross-origin cases that exposed it were CORS fetches that also sent a matching Timing-Allow-Origin. We did not test images with crossorigin="anonymous". In every case the resource entry itself existed; only the list was empty, with no error.
3. Plain HTTP on localhost and 127.0.0.1
Both http://localhost and http://127.0.0.1 gave identical results, and isSecureContext was true on both, since browsers treat loopback as secure. We did not test plain HTTP on a real hostname. Per the spec, serverTiming is limited to secure contexts, so expect an empty list over plain HTTP on a public domain (doc-based, not measured).
4. Sending it from Node / Express
// Node http (what we ran) or Express (same setHeader call)
const t0 = performance.now();
const rows = await db.query(...);
const dbMs = performance.now() - t0;
res.setHeader('Server-Timing', [
`db;dur=${dbMs.toFixed(1)}`,
'cache;desc="MISS"',
]); // an array becomes separate header lines, which Chromium merged
Headers must be set before the body is sent, so time the work first. To expose it to a page on another origin, also send Timing-Allow-Origin (the page origin, or *) and make the request a CORS request.
5. nginx (not executed here)
add_header Server-Timing 'app;desc="nginx"' always;
# doc-based options, not tested:
# add_header Server-Timing 'total;dur=$request_time'; # seconds, not ms
# add_header Server-Timing 'upstream;dur=$upstream_response_time';
Doc-based caveats: $request_time and $upstream_response_time are in seconds with millisecond resolution (for example 0.053), while dur is read as milliseconds, so the browser would show 0.053 ms. With nginx alone you would need to scale it, for example in njs or Lua. $upstream_response_time can also be a comma-separated list if several upstreams were tried, which would break the header format. add_header without always only applies to 2xx/3xx responses.
6. Viewing it
Doc-based, not checked in this test: Chrome DevTools shows Server-Timing metrics under Network, select a request, Timing tab, in a "Server Timing" section. From the command line, curl -sI URL | grep -i server-timing shows the raw header.
7. Do not leak too much
The header is visible to anyone who can load the URL. Timings and descriptions such as cache hits, database names or hostnames can help an attacker. Many sites only send it to logged-in staff or in non-production.
See the response headers of any URL, including Server-Timing and Timing-Allow-Origin, with curl -sI or the free HTTP headers tool.
Frequently asked questions
What does the Server-Timing header do?
It lets the server report its own timings, like db;dur=53.2, which the browser exposes in performance.getEntriesByType('navigation')[0].serverTiming as name, duration (ms) and description. Chromium 153 returned db, 53.2 for that header.
Why is serverTiming empty for my cross-origin request?
The resource must send Timing-Allow-Origin with * or the page origin. In Chromium 153, CORS alone gave an empty list; CORS plus Timing-Allow-Origin exposed it. A cross-origin image without crossorigin and a no-cors fetch stayed empty even with Timing-Allow-Origin: *.
What happens with a malformed Server-Timing value?
In Chromium 153, dur=abc became duration 0 but kept the metric. An entry with no name (;dur=5) stopped parsing, so valid metrics after it were lost. An unquoted description was cut at the first space.
Can I send Server-Timing as several header lines?
Yes. Two separate Server-Timing lines (db;dur=10 and app;dur=20) both appeared in serverTiming in Chromium 153, in order.
Does Server-Timing work on http://localhost?
Yes. On http://localhost and http://127.0.0.1, isSecureContext was true and serverTiming was filled. On plain HTTP with a real domain it is expected to be empty because it is limited to secure contexts (doc-based, not tested).
Written with AI assistance. Measured on 2026-10-01 in Chromium 153.0.8010.12 (Playwright, full chromium channel) against a small Node http server on localhost and 127.0.0.1, reading performance.getEntriesByType('navigation') and getEntriesByName() for subresources. nginx snippet not executed. PlainHeaders is our extension; the other methods are free.