# Server-Timing and Early Hints viewer

> Source: <https://webperf-snippets.net/Loading/Server-Timing-Early-Hints>

Lists the `Server-Timing` metrics that the server sends with the document and with each resource, and reports the 103 Early Hints timing of the navigation. These two signals show where the backend spends time before the first byte and whether the server lets the browser start fetching critical resources while it still builds the response.

### Why this matters

TTFB shows that the server is slow, not why. A `Server-Timing` header lets the backend report its own phases (database, cache lookup, rendering, edge) so they show up in DevTools and in the Performance API next to the network timings. Early Hints (`103`) lets the server send `Link: rel=preload` or `rel=preconnect` headers before the final response is ready, which hides part of the server think time behind useful downloads.

### What it reads

| Source           | Property                    | Meaning                                                   |
| ---------------- | --------------------------- | --------------------------------------------------------- |
| Navigation entry | `serverTiming`              | Metrics the document response reports                     |
| Resource entries | `serverTiming`              | Metrics each subresource response reports                 |
| Navigation entry | `firstInterimResponseStart` | When the first interim response (103 Early Hints) arrived |
| Navigation entry | `finalResponseHeadersStart` | When the headers of the final response arrived            |
| Navigation entry | `responseStart`             | When the first byte of the final response arrived         |

Each `Server-Timing` metric has a `name`, an optional `dur` (duration in milliseconds) and an optional `desc` (description). A metric without `dur` is reported with a duration of `0`.

### Snippet

```js
// Server-Timing & Early Hints Viewer
// https://webperf-snippets.net

(() => {
  // @shared isOwnRequest
  // True for the Resource Timing entry of a request a snippet made with ownRequestUrl: the page
  // did not make it, so no snippet counts it.
  function isOwnRequest(entry) {
    return entry.name.endsWith("#webperf-snippets");
  }
  // @end-shared isOwnRequest

  const SCRIPT = "Server-Timing-Early-Hints";
  const MAX_ITEMS = 50;

  const [nav] = performance.getEntriesByType("navigation");
  if (!nav) {
    return { script: SCRIPT, status: "error", error: "No navigation entry available" };
  }
  if (!Array.isArray(nav.serverTiming)) {
    return {
      script: SCRIPT,
      status: "unsupported",
      error: "Server-Timing entries are not supported in this browser",
    };
  }

  const round = (n) => Math.round(n * 100) / 100;
  // Times are relative to the time origin; a prerendered page starts at activationStart
  const activationStart = Math.max(0, nav.activationStart ?? 0);
  const rel = (t) => (t > 0 ? round(Math.max(0, t - activationStart)) : 0);

  const toItem = (entry, source, url) => ({
    source,
    url,
    name: entry.name,
    durationMs: round(entry.duration || 0),
    description: entry.description || "",
  });

  // Server-Timing entries of the document
  const all = nav.serverTiming.map((m) => toItem(m, "navigation", location.href));

  // Server-Timing entries of the resources. A cross-origin response without
  // Timing-Allow-Origin exposes no Server-Timing and reports zero for every size, so it is
  // counted as unreadable instead of being treated as a resource with no metrics.
  let resourcesWithServerTiming = 0;
  let corsRestrictedCount = 0;
  for (const r of performance.getEntriesByType("resource").filter((e) => !isOwnRequest(e))) {
    if (r.serverTiming.length > 0) {
      resourcesWithServerTiming++;
      for (const m of r.serverTiming) all.push(toItem(m, "resource", r.name));
      continue;
    }
    let crossOrigin = false;
    try {
      crossOrigin = new URL(r.name).origin !== location.origin;
    } catch {
      // Not a valid URL; treat as same-origin
    }
    if (crossOrigin && r.transferSize === 0 && r.encodedBodySize === 0 && r.decodedBodySize === 0) {
      corsRestrictedCount++;
    }
  }

  all.sort((a, b) => b.durationMs - a.durationMs);
  const items = all.slice(0, MAX_ITEMS);

  // 103 Early Hints. firstInterimResponseStart is 0 when the server sent no interim
  // response, or when the browser did not expose it (Early Hints needs HTTP/2 or later).
  const firstInterim = rel(nav.firstInterimResponseStart ?? 0);
  const finalHeaders = rel(nav.finalResponseHeadersStart ?? nav.responseStart);
  const earlyHintsReceived = firstInterim > 0;
  const navigation = {
    responseStartMs: rel(nav.responseStart),
    firstInterimResponseStartMs: firstInterim,
    finalResponseHeadersStartMs: finalHeaders,
    earlyHintsReceived,
    earlyHintsLeadMs: earlyHintsReceived ? round(Math.max(0, finalHeaders - firstInterim)) : 0,
    serverTimingCount: nav.serverTiming.length,
  };

  const issues = [];
  if (nav.serverTiming.length === 0) {
    issues.push({
      severity: "info",
      message:
        "The document response sends no Server-Timing header. Add one, for example Server-Timing: db;dur=53, to expose backend timings in DevTools and in the Performance API.",
    });
  }
  if (corsRestrictedCount > 0) {
    issues.push({
      severity: "info",
      message: `${corsRestrictedCount} cross-origin resource(s) hide their timing because they lack Timing-Allow-Origin, so their Server-Timing cannot be read. Add Timing-Allow-Origin to expose it.`,
    });
  }
  if (!earlyHintsReceived) {
    issues.push({
      severity: "info",
      message:
        "No 103 Early Hints response was observed for the document. If the server can send Link preload or preconnect headers before the final response, Early Hints lets the browser start those fetches earlier.",
    });
  }

  // Human output
  console.group("%cServer-Timing & Early Hints", "font-weight: bold; font-size: 14px;");
  console.log(
    `Document: ${nav.serverTiming.length} Server-Timing metric(s); resources with Server-Timing: ${resourcesWithServerTiming}`
  );
  console.log(
    earlyHintsReceived
      ? `103 Early Hints at ${firstInterim} ms, final headers at ${finalHeaders} ms (${navigation.earlyHintsLeadMs} ms lead)`
      : `No 103 Early Hints observed; final headers at ${finalHeaders} ms`
  );
  if (corsRestrictedCount > 0) {
    console.log(`${corsRestrictedCount} cross-origin resource(s) unreadable (no Timing-Allow-Origin)`);
  }
  if (items.length > 0) console.table(items);
  console.groupEnd();

  return {
    script: SCRIPT,
    status: "ok",
    count: all.length,
    corsLimitedAnalysis: corsRestrictedCount > 0,
    details: {
      navigation,
      resourcesWithServerTiming,
      corsRestrictedCount,
    },
    items,
    issues,
  };
})();
```

### Understanding the results

The snippet prints a summary and a table in the console, and returns a structured object.

### Metrics

`items` holds up to 50 metrics, slowest first, with the fields `source` (`navigation` or `resource`), `url`, `name`, `durationMs` and `description`. `count` is the total number of metrics found, including those beyond the first 50.

### Early Hints timing

`details.navigation` reports:

- `firstInterimResponseStartMs`: when the 103 response arrived, or `0` when none was observed.
- `finalResponseHeadersStartMs`: when the final response headers arrived.
- `earlyHintsLeadMs`: how much earlier the browser learned about the hinted resources than it would have without Early Hints.
- `earlyHintsReceived`: `true` when a 103 response was observed.

Times are relative to the start of the navigation, or to `activationStart` on a prerendered page.

> **Warning:** Chrome uses Early Hints for navigations over HTTP/2 and HTTP/3. A server that sends a 103 over HTTP/1.1 does not produce a `firstInterimResponseStart` value.

### Cross-origin resources

A cross-origin resource exposes its `Server-Timing` metrics only when its response sends a `Timing-Allow-Origin` header that permits the page origin. Without that header the browser reports an empty `serverTiming` list and zero for every size, so the snippet cannot tell a resource with no metrics from a hidden one. It counts the resources that look hidden (cross-origin with zero sizes) in `details.corsRestrictedCount`, sets `corsLimitedAnalysis` to `true` and adds an issue. `details.resourcesWithServerTiming` counts the resources whose metrics were read.

### Adding Server-Timing to a response

```http
Server-Timing: db;dur=53.2;desc="Database", cache;desc=HIT, total;dur=120
```

```http
Timing-Allow-Origin: https://example.com
```

Use a short metric name and do not expose internal details, since any script on the page can read these values.

### Sending Early Hints

```http
HTTP/2 103
Link: </styles/app.css>; rel=preload; as=style
Link: <https://cdn.example.com>; rel=preconnect
```

Send the same `Link` headers only for resources that every response for the URL needs, such as the main stylesheet or the LCP image.

### Browser support

The snippet needs these features to run.

| Feature                                                                                               | Chrome | Edge | Firefox | Safari |
| ----------------------------------------------------------------------------------------------------- | :----: | :--: | :-----: | :----: |
| [PerformanceNavigationTiming](https://developer.mozilla.org/docs/Web/API/PerformanceNavigationTiming) |  ✅ 57  | ✅ 12 |   ✅ 58  |  ✅ 15  |

The snippet reports on these features. It runs without them, and its advice on each one applies where it is supported.

| Feature                                                                                                                                               | Chrome |  Edge | Firefox | Safari |
| ----------------------------------------------------------------------------------------------------------------------------------------------------- | :----: | :---: | :-----: | :----: |
| [PerformanceServerTiming](https://developer.mozilla.org/docs/Web/API/PerformanceServerTiming)                                                         |  ✅ 65  |  ✅ 79 |   ✅ 61  | ✅ 16.4 |
| [PerformanceResourceTiming.firstInterimResponseStart](https://developer.mozilla.org/docs/Web/API/PerformanceResourceTiming/firstInterimResponseStart) |  ✅ 115 | ✅ 115 |  ✅ 152  | ✅ 26.4 |
| [PerformanceResourceTiming.finalResponseHeadersStart](https://developer.mozilla.org/docs/Web/API/PerformanceResourceTiming/finalResponseHeadersStart) |  ✅ 133 | ✅ 133 |  ✅ 152  | ✅ 26.4 |

Source: [MDN browser compatibility data](https://github.com/mdn/browser-compat-data), version 8.1.4.

Where a property is missing the snippet reports `0` for it.

### Further reading

- [Server-Timing](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Server-Timing) | MDN
- [Timing-Allow-Origin](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Timing-Allow-Origin) | MDN
- [Early Hints](https://developer.chrome.com/docs/web-platform/early-hints) | Chrome Developers
- [Optimize TTFB](https://web.dev/articles/optimize-ttfb) | web.dev
- [TTFB sub-parts](https://webperf-snippets.net/Loading/TTFB-Sub-Parts) | Break the time to first byte into its phases
