Server-Timing 响应头允许服务端给响应附加具名测量结果,浏览器开发者工具和性能接口随后可以把这些结果与对应资源请求关联起来。当网络瀑布图只能显示等待很久,却无法说明后端是否在查询数据库、渲染页面或执行其他操作时,这类信息很有帮助。但它提供的是服务端选择上报的观测结果,并不是浏览器独立测得这些内部操作的耗时,因此仍然需要理解测量范围和产生位置。
正确的时长语法是 db;dur=42, render;dur=81,建议使用毫秒作为单位。浏览器解析数值,但无法验证发送方实际采用的单位。直接写 db=42,并不是这项协议表达时长的方式。每个指标有名称,也可以带时长和描述参数。生产环境应采用含义稳定的名称,从实际插桩中得到数值。描述可以解释测量对象,但不应包含响应本来不该披露的原始查询、身份编号或内部细节。浏览器能够展示字段,不代表所有调试内容都适合跟随响应对外发送。
让数值对应明确的时间区间
服务端的多个测量值不一定能加成浏览器看到的请求总耗时。它们可能覆盖嵌套操作、相互重叠的工作,也可能只覆盖处理路径的一部分。只有数据库时间与渲染时间的定义允许相加时,加总才有意义。网络传输、测量区间之外的排队、缓存和客户端处理,都可能解释两侧数字的差异,并不一定说明某个时钟有错误。应先弄清每个数值从哪里开始、在哪里结束。
这个响应头也不会自动生成分布式链路。它在单个响应上命名测量结果,并没有自动提供父子关系、跨服务传播或采集器存储。可以用它展示少量对请求有帮助的测量,再通过日志或链路关联获取更深的解释。当响应经过缓存和代理时,还应检查最终交付的响应头,确定这些数字由哪个组件产生。读取一个字段与证明当前源站执行了相应工作,是不同层次的证据。
坏掉的样子
跨源资源存在两个容易混淆的权限边界。CORS 允许脚本读取响应,而 Access-Control-Expose-Headers 可以让 response.headers 暴露 Server-Timing 字段。Timing-Allow-Origin 则控制相应的详细资源计时信息是否可见。安全上下文与浏览器隐私规则还可能进一步限制计时暴露。因此,脚本成功读取响应头,并不能证明 PerformanceResourceTiming.serverTiming 数组一定包含同样的数据。排查时需要分别验证,不能只用其中一个接口有值就推断另一项能力已经成立。
另一个错误,是把解析演示当成真实性能测量。服务端发送固定时长,只能证明浏览器怎样解析这个数字,不能说明处理器实际运行了那么久。同样,计时数组为空,也不能证明服务端没有做任何工作。应先核对来源授权与真实线上响应头,再检查测量代码放置的位置。公开名称与说明还要符合接收对象的范围,避免为了排障而把不该公开的后端结构随响应一起暴露出去。
快速验证
将代码保存为 server-timing.py,执行 python3 server-timing.py –serve;此前记录使用 Python 3.14.4;本轮没有重跑套接字或浏览器测试。脚本在两个不同的临时端口上启动本机回环 HTTP 服务,因此它们是不同来源,然后输出待打开的页面地址。页面会请求两份资源,把响应头内容与浏览器解析出的性能条目同时显示出来。结束后按控制键加 C 关闭两个服务;不带服务参数执行时,只检查响应头并退出,不会自动运行浏览器。
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)
两份响应都通过 CORS 允许页面来源,都显式暴露 Server-Timing,也都携带同样的合成数据库与渲染时长。只有 allow 路径额外提供 Timing-Allow-Origin。在此前 Chrome 152 的观察记录中,两侧脚本都读到 db;dur=42, render;dur=81,但未授权的计时数组为空,已授权数组则包含名为 db、时长四十二,以及名为 render、时长八十一的两项结果。这份浏览器证据与 Python 的线上响应头断言分别检查,避免用非浏览器客户端代替实际可见性验证。
这里的固定数字有意只用于验证语法和暴露范围,不是数据库真正执行的时间。本地实验没有检查其他浏览器、生产代理、缓存响应或真实数据库插桩。实际接入时,应测量定义清楚的区间,在响应头已经提交之前写入字段,并让同类检查经过完整交付路径。还需要说明各名称的含义以及测量是否重叠,使阅读者不必自行想象一个服务端从未承诺过的可加总耗时分解。
来源
- W3C Server Timing Working Draft,响应头参数及性能条目的暴露规则,已核验 2026-10-15。
- MDN Server-Timing,时长语法、浏览器使用方式与信息披露边界,已核验 2026-09-13。
- MDN Timing-Allow-Origin,跨源资源详细计时数据的授权方式,已核验 2026-09-13。
- WHATWG Fetch,区分响应头暴露、计时授权与安全上下文处理,已核验 2026-10-15。