Server-Timing headers
Emit Server-Timing headers from your backend and fastmon breaks your server time into phases, cache status, and your own custom keys.
TTFB tells you the server was slow. It doesn't tell you which part was slow: the database, a third-party call, template rendering, or just a cache miss at the edge. The Server-Timing response header is the standard way for your backend to attach that breakdown to the response itself, and the browser exposes it to JavaScript. Fastmon reads it off every pageview.
New: already collecting
Server-Timing ingestion is live: the beacon already reads the header and every beacon carries it, fully normalized server-side. The Analytics dashboards on top of it are still being tuned, so the panels and groupings may shift while we settle the layout. Nothing you set up now is wasted; the data is being captured today.
What fastmon reads
The header rides on your document response, like any other backend header:
HTTP/2 200
Content-Type: text/html
Server-Timing: edge;desc="HIT", db;dur=53, app;dur=47.2, render;dur=12.4The browser parses it into the navigation entry's serverTiming array, and the beacon copies each entry off the main document's navigation timing. Per entry it reads three things, exactly as the spec defines them:
| Field | Source | What it is |
|---|---|---|
name | the metric name | db, cache, app: the label you choose |
dur | dur= | the reported duration in milliseconds (optional) |
desc | desc= | the free-text description, optional (e.g. a cache status) |
This is first-party by construction: the only Server-Timing the beacon can see is the one your own origin put on your own document. There's nothing to enable in the beacon and no third-party header is ever read. It's collected under every tracker setting; it carries no visitor information, just your server's own timing.
How fastmon normalizes it
The beacon ships the raw entries; the wire is forgeable, so the tracker's filtering is not trusted. Everything below is re-applied server-side before a single value is stored. Bad entries are dropped individually; a malformed metric never costs you the rest of the beacon.
Form rules. A metric is kept only if it passes:
- Name: lowercased, must match
^[a-z][a-z0-9._-]{0,31}$(starts with a letter, ≤ 32 chars). Atideways.layer.prefix is stripped first. - Duration: numeric and
0 ≤ dur ≤ 10,000,000 ms. Anything larger reads as a timestamp misuse and is dropped. A numeric-onlydesc(e.g.items;desc=3) is treated as the duration, but only if it fits the same window; a number outside it (Shopify'stheme;desc="190311530496"is a theme id) stays a description instead of becoming a six-year duration. - Description: ≤ 32 chars,
[a-zA-Z0-9 _.:/-]only; otherwise dropped. - Denylist & ID guard: infrastructure and trace keys (
requestid,traceparent,cfray,servedby,asn,country,ipv6) and config values that are not timings (Shopify'scompressionlevelandspeculationtags) are discarded outright, and any name or description that looks like a UUID or a long hex/base64 blob is dropped as a cardinality / PII guard. The CDN PoP keys (pop,fastly-pop,cdn-pop,x-qc-pop) are kept: they name the serving node, not the visitor.
Three tiers. What survives is sorted into:
- Promoted scalars (always kept): the eight backend phases, the two cache statuses, the page type, and the two labeled dimensions (
origin_host,logged_in). These become dedicated columns. - Tier-2 catalog (uncapped): a known set of names (
kafka,queue,dns,cpu,auth,gc,parse,session,theme,twig,apcu,compressiontime, and the members of the summed phases below) kept in the per-pageview duration/description maps. - Tier-3 custom (≤ 8 per pageview): any other valid name you invent.
Across both maps, durations are capped at 24 keys and descriptions at 8.
Fastmon's own metrics win
When the same phase could be filled by more than one metric, fastmon resolves it in a fixed order:
fm-*first-party alias › your site's mapping › built-in vendor alias (cf…) › built-in generic alias
Fastmon's edge injects an fm--prefixed set (fm-edge, fm-origin, fm-backend, fm-db, fm-render, fm-http, fm-search, fm-kv, fm-cdn-cache, fm-origin-cache, fm-pagetype). When an fm- metric is present it wins, because it's measured at fastmon's own edge rather than reported by the page; it can't be forged from the client and is consistent across every site. Below that comes a name you've mapped in your site settings, then the built-in aliases. The tables below list every recognized name in this precedence order, left to right: a vendor name like cfedge is preferred over the generic edge, and the first one that carries a duration wins. Your own metrics fill the phases the fm- set doesn't cover.
Backend phases
Recognized names fold into eight phases, shown as p50 / p75 / p95 over the time range, each with the number of pageviews that reported it. Four take the first alias that carries a duration; http, search, and kv sum every matching member; db takes the first relational alias and adds the document stores on top. Summed members also stay in the maps for drill-down. The fm-* alias always leads.
| Phase | Recognized names (in precedence order) | How |
|---|---|---|
edge_dur | fm-edge, cfedge, edge, time-elapsed | first w/ dur |
origin_dur | fm-origin, cforigin, origin, cdn-upstream-fbl | first w/ dur |
backend_dur | fm-backend, processing, total, app, be, wp-total, bootstrap | first w/ dur |
db_dur | fm-db, db, sql, rdbms; plus mongodb, sqlite | first w/ dur, + sum |
render_dur | fm-render, render, wp-template, view, ssr | first w/ dur |
http_dur | fm-http, http, fetch, api, ext | sum |
search_dur | fm-search, elasticsearch, opensearch, solr, search | sum |
kv_dur | fm-kv, redis, valkey, kv, memcache, cache | sum |
Phases you don't emit are simply absent (NULLs are skipped, never counted as zero). The percentiles let you see, for example, that db is your p95 problem while render is flat. Read them next to their count: a p75 over 12 pageviews says less than one over 50,000.
What counts where. db_dur is the primary datastore. The relational aliases are different names for the same measurement, so the first one wins; mongodb and sqlite are added on top, because a document store is the primary store wherever it appears (Tideways reports rdbms and mongodb as separate layers of one request). Search engines never count as database time. kv_dur is the whole key-value and cache tier, whichever engine backs it: a Redis object cache and the same cache on memcache land in the same column, and the per-key drill-down still says which engine took the time. apcu (in-process shared memory) and Shopify's edge_cart are in no sum and stay in the maps. Queue brokers (amqp, kafka, beanstalk, queue) stay drill-down only as well: synchronous broker time in a request is almost always a cheap publish.
All eight phases appear on the Server-Timing page, in the response breakdown waterfall, and as metrics and columns in the Explorer (the KV phase is labeled "KV / Cache"; the full page cache is the origin cache status, a different layer). The pageview drawer lists the phases nested: DB, Search, KV / Cache, HTTP, and Render under Backend, Backend under Origin, each with its share of the row that contains it, and the time not attributed to any layer as an "Unattributed" row.
Cache status
A cache verdict is carried as a description, not a duration (e.g. Server-Timing: cdn-cache;desc="HIT"). Fastmon tracks two layers independently:
| Layer | Recognized names (in precedence order) |
|---|---|
cdn | fm-cdn-cache, fm-cdn, cfcachestatus, cdn-cache, hit-state |
origin | fm-origin-cache, fpc, x-cache, cache |
CloudFront's bare flags are recognized as a fallback for the CDN layer: cdn-cache-hit → HIT, cdn-cache-miss → MISS, cdn-cache-refresh → REVALIDATED (an explicit CDN key still overrides them).
fm-fpc is gone
The origin-status key is fm-origin-cache. fm-fpc was the old spelling and is no longer recognized: it was the only first-party key calling the origin tier something other than origin, which next to fm-origin-age would have put two words for one tier in a single header. A header still sending fm-fpc shows it in the panel's top-keys list instead of silently filling nothing, so the mistake is visible. fm-cdn keeps working; fm-cdn-cache is the documented spelling.
The value is uppercased and normalized to one of the statuses fastmon stores. Fastly's compound forms collapse first (MISS-CLUSTER → MISS, HITPASS → PASS, HIT-STALE → STALE); anything outside the list below passes through as-is, capped to 12 characters, so a vendor-specific status is never silently dropped.
| Status | What it means |
|---|---|
HIT | Served from cache, no origin fetch. |
MISS | Not in cache; fetched from origin (and usually stored for next time). |
EXPIRED | A cached copy had expired, so it was refreshed from origin. |
STALE | A stale copy was served (stale-while-revalidate, or origin unreachable). |
REVALIDATED | The cached copy was checked against origin and confirmed still fresh. |
UPDATING | A stale copy was served while the cache refreshed it in the background. |
BYPASS | The cache was deliberately skipped for this request; origin served it. |
DYNAMIC | Treated as dynamic and uncacheable; always from origin. |
PASS | Passed through uncached (Fastly pass; HITPASS collapses here). |
NONE | No caching applied or configured for the response. |
Both layers use the same vocabulary. A pageview that reports no cache status is stored empty and shows as Unknown in the dashboard (it counts as unreported in the per-status share metrics). Together they give the fastest read on whether your edge cache is doing its job, right next to TTFB.
Cache age
The status says whether the visitor got a cached object; the age says how old it was. That is the other half of the question: a shop with a 95 % hit rate whose objects average four hours old is serving fast and stale.
Server-Timing: fm-cdn-cache;desc="HIT", fm-cdn-age;desc=42, fm-origin-age;desc=312| Key | Column | Meaning |
|---|---|---|
fm-cdn-age | cdn_age | Age of the delivered CDN object, in seconds |
fm-origin-age | origin_age | Age of the delivered origin object, in seconds |
Rules for the value:
- First-party keys only, no vendor aliases. A bare
agesays nothing about which tier it measured, and without the prefix we would not own the unit: a framework emittingcache-age;dur=500means milliseconds and the value would be stored as 500 seconds. - Seconds, in
desc, notdur.duris specified as a duration in milliseconds and DevTools draws it as a bar in the document request's timing view, so an age there would render as a backend phase that never happened. Adurspelling is still accepted, since it is what a site tries first. Send the bare number:fm-origin-age;desc=312is read,desc=312sis discarded. - Emit it on a cache hit only. On a miss the age is 0 and drags every percentile down.
- Values above a year are dropped rather than clamped, because a number that large is a timestamp or a millisecond value sent as seconds, not an age.
desc=0is a valid value: an object stored a moment ago.
Two columns, not one. A shop behind a CDN has both ages in the same pageview and they do not mean the same thing. The origin's header freezes inside the CDN object, so under a CDN hit the origin value is the age at the moment the CDN fetched, and only the sum is the age the visitor saw. The Server-Timing panel therefore reports three rows, cdn, origin, and content (the sum under a CDN hit), all in seconds and computed server-side, because the p75 of a sum is not the sum of the p75s.
Both ages are available as metrics (cdn_age, origin_age, with the usual avg and p50 to p99), as fields on the beacon rows and in the pageview detail next to the cache status they qualify, and through the MCP server's get_server_timing.
Page type
A reserved metric labels the kind of page, which becomes a dashboard dimension you can group and filter by. Send the label in the desc (the duration slot is numeric):
Server-Timing: pageType;desc="checkout"Fastmon accepts pageType / page_type and the first-party fm-pagetype, lowercases the label, and caps it at 32 chars. The header value always wins over the body-class detection you can enable with pagetype_ruleset, so it's the authoritative source whenever you can set it server-side.
Origin host and logged-in
Two more reserved metrics carry a label instead of a duration, and both become dimensions you can filter and group by. Unlike every other promoted column they have no vendor aliases: host, node, server, or user from an unknown framework could mean anything, so your site opts in by emitting the unambiguous first-party name.
Server-Timing: fm-host;desc="web-03", fm-loggedin;desc="no"fm-host → origin_host names the machine of your origin tier that rendered exactly this pageview. The value of the split shows in a cluster: when one node of five runs hot (a bad deploy, a full disk, a noisy neighbor), the aggregate backend_dur only shows a p95 that drifted, while the per-node split names the box to look at. Rules for the value:
- Send the short, stable hostname, not the FQDN and not a URL. On Kubernetes, use a stable name (StatefulSet ordinal, node name, deployment slot), never the pod name with its random suffix: that changes on every deploy and fills the dimension with values that are dead by the time you read them.
- A value over 32 characters is dropped, not truncated:
gethostname()returns the full internal FQDN on AWS and k8s, and a truncated hostname is a wrong node name (two machines differing only after character 32 would merge into one bucket). If the column stays empty, your value is too long.
fm-loggedin → logged_in says whether the page was rendered for a signed-in visitor. That's the line that explains cache behavior: a personalized request bypasses the full-page cache by design, so it reports MISS/PASS and a multiple of the anonymous backend time. Without the flag both populations sit in one average and every cache-hit-rate number is quietly wrong; with it, "our TTFB got worse" resolves into "the logged-in share grew".
yes / no is enough; true/false, 1/0, y/n, in/out, and anonymous are normalized onto them. A third state of your own (say b2b) passes through if it is slug-shaped (^[a-z][a-z0-9_-]{0,15}$); anything else is dropped rather than guessed at.
The coarse flag only
fm-loggedin may carry the render mode and nothing else: no customer number, no customer-group ID, no role, no username. fm-host describes your server, never the visitor; keep request and session IDs out of it. The collector enforces the shape (length caps, ID heuristic); keeping identifiers out is your side of the contract.
Pageviews that report neither key show as Not reported; the empty string is a real filter value for exactly that slice, and logged_in ≠ yes is the "anonymous, cacheable traffic only" query.
Emitting the header
Set the header on your HTML document response. The format is a comma-separated list of metrics, each name optionally followed by ;dur=<ms> and/or ;desc="<text>".
@app.after_request
def add_server_timing(resp):
resp.headers["Server-Timing"] = (
f"db;dur={db_ms:.1f}, "
f"render;dur={render_ms:.1f}, "
f'cdn-cache;desc="{cache_status}", '
f'pageType;desc="{page_type}"'
)
return respres.setHeader('Server-Timing', [
`db;dur=${dbMs.toFixed(1)}`,
`http;dur=${apiMs.toFixed(1)}`,
`render;dur=${renderMs.toFixed(1)}`,
].join(', '));// src/EventListener/ServerTimingListener.php
#[AsEventListener(event: ResponseEvent::class)]
final class ServerTimingListener
{
public function __invoke(ResponseEvent $event): void
{
$event->getResponse()->headers->set('Server-Timing', sprintf(
'db;dur=%.1f, render;dur=%.1f, cdn-cache;desc="%s"',
$dbMs,
$renderMs,
$cacheStatus,
));
}
}Most CDNs (Cloudflare, Fastly, CloudFront) can append their own cache/edge metric to the same header at the edge, so the browser sees both your origin's phases and the edge's cache verdict in one place, and fastmon already knows their native names (cfcachestatus, cfedge, the CloudFront flags).
Verifying it
- Open your site, then DevTools → Network → click the document request → Headers. You should see your
Server-Timingheader on the response. - In the same request's Timing tab, Chrome renders the
Server-Timingentries inline: a quick sanity check that the syntax parsed. - In fastmon, open Analytics → Server-Timing. New data shows up on the next beacons; give it a few pageviews to accumulate before reading percentiles.
Common surprises
- Only the document response counts. Fastmon reads the navigation entry, not the
Server-Timingon your sub-resources (scripts, images, API calls). For in-page request timing, see Fetch/XHR instead. duris optional. A metric with only adesc(like a cacheHIT/MISSor a page type) is valid and useful: that's exactly how the cache-status and page-type fields are fed.- Cross-origin documents need the timing exposed. A same-origin document is fine by default; if your HTML is served from a different origin,
serverTimingis only populated when that response also sendsTiming-Allow-Origin. - Don't leak internals in
desc. The description is free text that ships to the browser and back. Anything that looks like a UUID or a long token is dropped, but keep it to coarse labels (HIT,MISS, region codes) regardless; never put query text, IDs, or secrets there. - Eight custom keys, max. Beyond the catalog, only the first eight unknown names per pageview are kept. If you're inventing many custom metrics, fold the durable ones into a name fastmon already recognizes.
- Server time longer than TTFB is not bad data. Behind 103 Early Hints the first byte is the edge's interim response, and the phases describe the final one;
edge_dur + backend_durhas to fit intottfb_final, not intottfb. See TTFB → Early Hints.
Related
- TTFB: the metric Server-Timing explains.
- The RUM beacon: every field the beacon reads, including
Server-Timing. - Analytics: where the Server-Timing view lives.
- Query Server Timing: the API behind the panel.