# Server-Timing Header

> Learn how the Server-Timing header communicates server-side performance metrics to browsers. Analyze backend timing, database queries, and optimize performance.

Source: https://howhttpworks.com/headers/server-timing
Last reviewed: 2026-10-05

> **TL;DR:** `Server-Timing` lets your server tell the browser where its time went, for example `Server-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. Report `dur` in milliseconds, set the header before the body starts, and add `Timing-Allow-Origin` if 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](https://www.w3.org/TR/server-timing/#the-server-timing-header-field) defines the grammar and the browser interface.

## How Server-Timing Works

A metric can be just a label (examples on this page are trimmed):

```http
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

```text
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:

```http
Server-Timing: cache;desc="hit"
```

```http
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`:

```javascript
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()`](https://nodejs.org/api/perf_hooks.html#performancenow) 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:

```bash
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`:

```javascript
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](https://flask.palletsprojects.com/en/stable/api/#flask.Flask.after_request) make a handler-duration metric easy. This runnable app times the gap between the two hooks:

```python
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](https://docs.python.org/3/library/time.html#time.perf_counter) 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`:

```python
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](https://docs.djangoproject.com/en/5.2/topics/http/middleware/#dealing-with-streaming-responses) 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`:

```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](https://pkg.go.dev/net/http#ResponseWriter).

## Client-Side Access

In the page, read the navigation entry's metrics and watch resource entries as they complete:

```javascript
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`](https://developer.mozilla.org/en-US/docs/Web/API/PerformanceObserver/observe) 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:

```javascript
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](https://react.dev/reference/react/useEffect) 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](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Server-Timing#server-timing_as_http_trailer) 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:

```javascript
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](https://howhttpworks.com/headers/x-request-id) or [traceparent](https://howhttpworks.com/headers/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](https://developer.mozilla.org/en-US/docs/Web/API/PerformanceServerTiming#browser_compatibility) 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:

```http
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](https://www.w3.org/TR/server-timing/).

## Related Headers

- [Timing-Allow-Origin](https://howhttpworks.com/headers/timing-allow-origin)
- [X-Response-Time](https://howhttpworks.com/headers/x-response-time)
- [X-Request-ID](https://howhttpworks.com/headers/x-request-id)
- [traceparent](https://howhttpworks.com/headers/traceparent)
