Response header
< Server-Timing: db;dur=53, app;dur=47.2Server-Timing Header
Learn how the Server-Timing header communicates server-side performance metrics to browsers. Analyze backend timing, database queries, and optimize performance.
- Direction
- Response
- Category
- Diagnostics
On this page
TL;DR:
Server-Timinglets your server tell the browser where its time went, for exampleServer-Timing: db;dur=53, cache;desc="hit". The metrics show up in DevTools under the request’s Timing tab and in JavaScript through the Performance API. Reportdurin milliseconds, set the header before the body starts, and addTiming-Allow-Originif another origin’s page needs to read the values.
You decide what to measure and send; the browser just displays it. Nothing gets timed automatically, whether that’s your database queries or the browser’s time to first byte. The Server Timing specification defines the grammar and the browser interface.
How Server-Timing Works
A metric can be just a label (examples on this page are trimmed):
Server-Timing: cache;desc="hit"
DevTools shows it with the request. In JavaScript it becomes a PerformanceServerTiming entry with name, duration and description. When you leave out dur, the duration reads as zero, which only means nobody measured it.
Syntax
Server-Timing: metric-name;dur=<milliseconds>;desc="description", another-metric
Each metric name is an HTTP token, and both dur and desc are optional. The spec says durations should be in milliseconds, but nothing checks; if your code measures seconds, the browser will show seconds as milliseconds. Send several metrics comma-separated on one line or as separate header lines. Obsolete folded continuation lines aren’t allowed.
Common Examples
Some metrics are states (a cache hit) and some are durations (a file read). A description-only metric is perfectly valid. For a duration, time the operation itself, end minus start. Reporting “time since the request arrived” under each operation’s name makes every later metric include all the earlier ones.
To track cache hits and misses, put the result in the description:
Server-Timing: cache;desc="hit"
Server-Timing: cache;desc="miss", db;desc="queried"
If you also time the lookup, add a dur measured around the lookup alone; a hit still takes some time. Keep each metric meaning the same thing on both paths, or your dashboard ends up comparing database time on misses with total handler time on hits.
Server Implementation
This complete Node server times JSON serialization and reports it. Save it as timing.mjs and run node timing.mjs:
import http from 'node:http'
import { performance } from 'node:perf_hooks'
http.createServer((req, res) => {
const start = performance.now()
const body = JSON.stringify({ status: 'ok' })
const duration = performance.now() - start
res.setHeader('Content-Type', 'application/json')
res.setHeader('Server-Timing', `serialize;dur=${duration.toFixed(3)}`)
res.end(body)
}).listen(3000, '127.0.0.1')
performance.now() gives a high-resolution timestamp in milliseconds. The metric covers serialization only; writing to the socket and network transit aren’t in it. Check the value:
curl -sS -D - http://127.0.0.1:3000/ -o /dev/null
Express: measure separate operations
This helper times each operation, records it even when the operation throws, and escapes the description. Save it as app.mjs, install Express, and run it next to a readable data.json:
import express from 'express'
import { readFile } from 'node:fs/promises'
import { performance } from 'node:perf_hooks'
class ServerTiming {
metrics = []
async measure(name, description, operation) {
const start = performance.now()
try {
return await operation()
} finally {
const duration = performance.now() - start
const quoted = description.replace(/["\\]/g, '\\$&')
this.metrics.push(`${name};dur=${duration.toFixed(3)};desc="${quoted}"`)
}
}
toString() { return this.metrics.join(', ') }
}
const app = express()
app.get('/data', async (req, res) => {
const timing = new ServerTiming()
const text = await timing.measure('read', 'Read JSON', () => readFile('data.json', 'utf8'))
const data = await timing.measure('parse', 'Parse JSON', () => JSON.parse(text))
res.set('Server-Timing', timing.toString())
res.json(data)
})
app.listen(3000, '127.0.0.1')
Stick to fixed metric names and descriptions. The escaping handles quotes and backslashes, but request text has no business in a response header, and a line break in the value would break the header entirely. To time database or cache calls, wrap the call itself in measure() rather than recording a mark relative to request arrival.
Flask
Flask’s request hooks make a handler-duration metric easy. This runnable app times the gap between the two hooks:
from time import perf_counter
from flask import Flask, g, jsonify
app = Flask(__name__)
@app.before_request
def start_timer():
g.timing_start = perf_counter()
@app.after_request
def attach_timing(response):
elapsed = (perf_counter() - g.timing_start) * 1000
response.headers['Server-Timing'] = f'handler;dur={elapsed:.3f}'
return response
@app.get('/')
def index():
return jsonify(status='ok')
Save as app.py and run flask --app app run. perf_counter() is Python’s clock for measuring durations and returns seconds, hence the * 1000. The hooks miss anything after after_request, such as generating a streamed body or delivering it over the network.
Django middleware
For a synchronous Django application, place this in yourapp/middleware.py and add yourapp.middleware.ServerTimingMiddleware to MIDDLEWARE:
from time import perf_counter
class ServerTimingMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
start = perf_counter()
response = self.get_response(request)
duration = (perf_counter() - start) * 1000
response['Server-Timing'] = f'handler;dur={duration:.3f}'
return response
Where you put it in MIDDLEWARE decides which other middleware falls inside the timing. A streaming response comes back before its iterator has run, so the metric won’t cover the stream; leave the iterator alone rather than consuming it just to time it. Django’s middleware documentation covers streaming responses.
Go net/http
Take the measurement before you write the response. Save this alternative server as main.go and run go run main.go:
package main
import (
"encoding/json"
"fmt"
"log"
"net/http"
"time"
)
func main() {
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
body, err := json.Marshal(map[string]string{"status": "ok"})
if err != nil {
http.Error(w, "encoding failed", http.StatusInternalServerError)
return
}
elapsed := float64(time.Since(start)) / float64(time.Millisecond)
w.Header().Set("Server-Timing", fmt.Sprintf("serialize;dur=%.3f", elapsed))
w.Header().Set("Content-Type", "application/json")
w.Write(body)
})
log.Fatal(http.ListenAndServe("127.0.0.1:3000", nil))
}
The first Write sends the headers if WriteHeader hasn’t already. So middleware that sets Server-Timing after the inner handler has written the body is too late; the header never goes out. See Go’s ResponseWriter contract.
Client-Side Access
In the page, read the navigation entry’s metrics and watch resource entries as they complete:
const navigation = performance.getEntriesByType('navigation')[0]
console.table(navigation?.serverTiming ?? [])
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
console.log(entry.name, entry.serverTiming)
}
})
observer.observe({ type: 'resource', buffered: true })
buffered: true also delivers entries recorded before the observer started. Match entries by URL. Grabbing the last resource entry right after fetch() resolves often gets the wrong one: fetch resolves once headers arrive, before the body is read and the entry is recorded, and other resources may finish in the meantime.
React resource observer
This hook subscribes to entries for one URL and disconnects when the component unmounts or the URL changes:
import { useEffect, useState } from 'react'
export function useServerTiming(url) {
const [metrics, setMetrics] = useState([])
useEffect(() => {
setMetrics([])
if (typeof PerformanceObserver === 'undefined' ||
!PerformanceObserver.supportedEntryTypes.includes('resource')) return
const target = new URL(url, window.location.href).href
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.name === target) {
setMetrics(Array.from(entry.serverTiming ?? [], (metric) => ({
name: metric.name,
duration: metric.duration,
description: metric.description
})))
}
}
})
observer.observe({ type: 'resource', buffered: true })
return () => observer.disconnect()
}, [url])
return metrics
}
The hook only watches; your code still has to fetch the URL somewhere else. If the URL is requested several times, it keeps the latest matching entry. Call useServerTiming('/data') in the component that shows the numbers. React’s Effect contract expects cleanup to undo setup, which is why the observer disconnects.
Getting Server-Timing right
Set the header before the first byte of the body goes out. Once the response has started, a timer that stops later has nowhere to go. A metric for total body-generation time would need an HTTP trailer, and browser Fetch doesn’t expose trailers, as MDN notes.
Nested and parallel spans overlap, so the durations won’t add up to total server time. Pin down where each metric starts and stops before you compare it across requests.
Monitoring and Analytics
Tie each measurement to its request with the resource entry’s URL and the metric’s name. Remember these are the server’s own numbers about itself; cross-check them against server-side monitoring before drawing conclusions.
For a quick browser-side table, swap the resource observer’s callback above for one that collects a row per metric:
const rows = []
for (const entry of list.getEntries()) {
for (const metric of entry.serverTiming ?? []) {
rows.push({
path: new URL(entry.name).pathname,
metric: metric.name,
milliseconds: metric.duration,
description: metric.description
})
}
}
console.table(rows)
That’s just the callback body; list comes from PerformanceObserver. If you ship these rows to monitoring, send only the fields you need, since full resource URLs may contain user data or tokens. To follow one request across services, pair the timings with X-Request-ID or traceparent; durations alone can’t tell you which request they came from.
Browser Support
Check that serverTiming exists on the entry before you use it. Some browsers only expose it in secure contexts (HTTPS); MDN’s compatibility data has the details.
Anyone who gets the response can read the names and descriptions, so keep SQL, credentials and user IDs out of them. If the metrics themselves are sensitive, only send them to trusted clients, for example behind an internal flag. Timing-Allow-Origin won’t hide the raw header.
Privacy Considerations for Server-Timing
For a cross-origin resource, this header lets pages on the named origin read its timing metrics through the Performance API:
Timing-Allow-Origin: https://app.example.com
If the timing-allow check fails, the entry’s serverTiming array is empty. That’s a separate permission from CORS, which controls whether response.headers.get() can read the raw header. See the Server Timing interface definition.
Related Headers
Frequently asked questions
What is Server-Timing?
It is a response header where your server reports its own timings, such as db;dur=53 for 53 ms of database work. Browsers show the metrics in DevTools and expose them to JavaScript through the Performance API, with limits for cross-origin resources.
How do I read Server-Timing in JavaScript?
Use Performance API: performance.getEntriesByType("resource").forEach(e => console.log(e.serverTiming)). Each entry has name, duration, and description.
What metrics should I include?
Common metrics: database query time, cache lookup, external API calls, template rendering. Include what helps diagnose performance issues.
Is Server-Timing secure?
Anyone who receives the response can read the header. Timing-Allow-Origin only controls cross-origin access through the Performance API; it does not hide the header. Keep sensitive details out of names and descriptions.
Sources
Related
TTFB (Time to First Byte)
TTFB is the time from starting a request until the first byte of the response arrives. Learn what it includes, how to measure it and what usually inflates it.
Access-Control-Max-Age Header
Learn how Access-Control-Max-Age specifies how long browsers can cache CORS preflight results. Reduce preflight requests and improve cross-origin performance.
Content-Encoding
Learn how Content-Encoding specifies compression algorithms (gzip, br, deflate) used to encode response bodies. Reduce bandwidth and improve load times.
Early-Hints
Learn how 103 Early Hints allows servers to send preload hints while preparing the main response. Improve page load performance with early resource loading.