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 nine 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 nine phases, shown as p50 / p75 / p95 over the time range, each with the number of pageviews that reported it. Five 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
code_durfm-code, fm-unknown, unknownfirst w/ dur

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.

On a CDN hit the origin phases are not stored. A CDN replays the origin's header from the request that filled the cache. On a pageview whose CDN status is HIT, STALE, UPDATING, or REVALIDATED, fastmon drops the origin, backend, db, http, render, search, kv, and code durations and the custom timed keys. edge_dur, the cache statuses, ages, TTLs, and label keys stay.

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. code_dur is the time spent in your own code outside every other layer, shown as "Code". The Shopware plugin passes the Tideways layer unknown through, which lands here.

All nine 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, Render, and Code 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-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.

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 age says how old the delivered object was, in seconds. A shop with a 95 % hit rate and objects four hours old serves fast and stale.

Server-Timing: fm-cdn-cache;desc="HIT", fm-cdn-age;desc=42, fm-origin-age;desc=312
Keys (precedence order)ColumnMeaning
fm-cdn-age, cdn-cache-agecdn_ageAge of the delivered CDN object, in seconds
fm-origin-age, origin-cache-ageorigin_ageAge of the delivered origin object, in seconds

Rules for the value:

  • Seconds, in desc. fm-origin-age;desc=312 is read, desc=312s is discarded. The fm-* keys also accept dur, the vendor keys drop it.
  • fm-* wins when a site sends it next to the vendor key. A CDN that emits cdn-cache-age fills the age without any change on your site.
  • 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. desc=0 is valid: an object stored a moment ago.

Under a CDN hit only the sum is the age the visitor saw. The origin's header freezes inside the CDN object. The Server-Timing panel reports cdn, origin, and content (the sum under a CDN hit), all in seconds.

Cache TTL

The TTL is the freshness the object had left when it was delivered, in seconds. Age plus TTL is the configured lifetime.

Keys (precedence order)Column
fm-cdn-ttl, cdn-cache-ttlcdn_ttl
fm-origin-ttl, origin-cache-ttlorigin_ttl

The value rules are those of the age. The panel shows p50, p75, and p95 per tier, without a content row. The Cache page draws age and TTL as one bar per tier and per page type, and over time, where a purge shows as the age dropping to zero.

All four are metrics with the usual avg and p50 to p99, and metric range filters in seconds. A pageview without the key never matches such a filter. They also appear on beacon rows, in the pageview detail, in notification rules, on the visitor journey, and in 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>".

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