fastmon Docs
Guides

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.4

The 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:

FieldSourceWhat it is
namethe metric namedb, cache, app: the label you choose
durdur=the reported duration in milliseconds (optional)
descdesc=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). A tideways.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-only desc (e.g. items;desc=3) is treated as the duration, but only if it fits the same window; a number outside it (Shopify's theme;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's compressionlevel and speculationtags) 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:

  1. 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.
  2. 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.
  3. 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.

PhaseRecognized names (in precedence order)How
edge_durfm-edge, cfedge, edge, time-elapsedfirst w/ dur
origin_durfm-origin, cforigin, origin, cdn-upstream-fblfirst w/ dur
backend_durfm-backend, processing, total, app, be, wp-total, bootstrapfirst w/ dur
db_durfm-db, db, sql, rdbms; plus mongodb, sqlitefirst w/ dur, + sum
render_durfm-render, render, wp-template, view, ssrfirst w/ dur
http_durfm-http, http, fetch, api, extsum
search_durfm-search, elasticsearch, opensearch, solr, searchsum
kv_durfm-kv, redis, valkey, kv, memcache, cachesum

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:

LayerRecognized names (in precedence order)
cdnfm-cdn-cache, fm-cdn, cfcachestatus, cdn-cache, hit-state
originfm-origin-cache, fpc, x-cache, cache

CloudFront's bare flags are recognized as a fallback for the CDN layer: cdn-cache-hitHIT, cdn-cache-missMISS, cdn-cache-refreshREVALIDATED (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-CLUSTERMISS, HITPASSPASS, HIT-STALESTALE); anything outside the list below passes through as-is, capped to 12 characters, so a vendor-specific status is never silently dropped.

StatusWhat it means
HITServed from cache, no origin fetch.
MISSNot in cache; fetched from origin (and usually stored for next time).
EXPIREDA cached copy had expired, so it was refreshed from origin.
STALEA stale copy was served (stale-while-revalidate, or origin unreachable).
REVALIDATEDThe cached copy was checked against origin and confirmed still fresh.
UPDATINGA stale copy was served while the cache refreshed it in the background.
BYPASSThe cache was deliberately skipped for this request; origin served it.
DYNAMICTreated as dynamic and uncacheable; always from origin.
PASSPassed through uncached (Fastly pass; HITPASS collapses here).
NONENo 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
KeyColumnMeaning
fm-cdn-agecdn_ageAge of the delivered CDN object, in seconds
fm-origin-ageorigin_ageAge of the delivered origin object, in seconds

Rules for the value:

  • First-party keys only, no vendor aliases. A bare age says nothing about which tier it measured, and without the prefix we would not own the unit: a framework emitting cache-age;dur=500 means milliseconds and the value would be stored as 500 seconds.
  • Seconds, in desc, not dur. dur is 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. A dur spelling is still accepted, since it is what a site tries first. Send the bare number: fm-origin-age;desc=312 is read, desc=312s is 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=0 is 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-hostorigin_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-loggedinlogged_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>".

Flask / Python
@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 resp
Express / Node
res.setHeader('Server-Timing', [
  `db;dur=${dbMs.toFixed(1)}`,
  `http;dur=${apiMs.toFixed(1)}`,
  `render;dur=${renderMs.toFixed(1)}`,
].join(', '));
Symfony / PHP
// 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

  1. Open your site, then DevTools → Network → click the document request → Headers. You should see your Server-Timing header on the response.
  2. In the same request's Timing tab, Chrome renders the Server-Timing entries inline: a quick sanity check that the syntax parsed.
  3. 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-Timing on your sub-resources (scripts, images, API calls). For in-page request timing, see Fetch/XHR instead.
  • dur is optional. A metric with only a desc (like a cache HIT/MISS or 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, serverTiming is only populated when that response also sends Timing-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_dur has to fit into ttfb_final, not into ttfb. See TTFB → Early Hints.

On this page