Capturing Font Timing with the Resource Timing API
This page is part of the Real-User Monitoring for Web Fonts guide, itself part of the Font Performance Monitoring & Auditing blueprint. Here we solve one precise problem: turning a raw PerformanceResourceTiming entry for a font request into a phase-by-phase breakdown — DNS, TCP/TLS connect, time-to-first-byte, and byte transfer — and shipping it to a collector with navigator.sendBeacon(). A single "font took 240ms" number is not actionable; knowing that 180ms of it was connection setup tells you to preconnect, while 180ms of transfer tells you to subset.
Each phase points at a different fix. DNS and connect time is paid once per origin and is eliminated by reusing the document's HTTP/2 connection (self-hosting) or by a preconnect resource hint. TTFB reflects server think-time and one round trip, addressed by edge caching and CDN placement. Transfer time scales with the file's compressed size, addressed by WOFF2 and aggressive subsetting. Collapsing all four into one duration throws away the signal that tells you which lever to pull — which is exactly why the breakdown below is worth the few lines of code.
Prerequisites
You need WOFF2 (or WOFF) assets actually being fetched over the network — inlined or data-URI fonts produce no resource timing entry. The script must run in a browser that exposes performance.getEntriesByType('resource') and PerformanceResourceTiming (all current engines do). Crucially, for cross-origin fonts — anything served from a different origin than the document, including most CDNs — the font response must include the Timing-Allow-Origin header. Without it, the detailed phase timestamps (domainLookupStart, connectStart, requestStart, responseStart) are zeroed for privacy, and only startTime, duration, and a zeroed transferSize survive. Same-origin fonts expose full timing with no extra header.
Required response header on cross-origin font assets
Timing-Allow-Origin: https://www.example.com
# or, more permissively:
Timing-Allow-Origin: *
Implementation
The primary code block reads every font resource entry, derives the four phases, classifies cache hits, and beacons a compact payload on the visibility transition.
Font Resource Timing capture + beacon
function collectFontTimings() {
const entries = performance.getEntriesByType('resource');
return entries
.filter((e) =>
(e.initiatorType === 'css' || e.initiatorType === 'link') &&
/\.woff2?(\?|$)/i.test(e.name)
)
.map((e) => {
// A cross-origin entry without Timing-Allow-Origin zeroes these phases.
const taoBlocked = e.requestStart === 0 && e.startTime > 0;
return {
url: new URL(e.name).pathname,
// DNS resolution time.
dns: round(e.domainLookupEnd - e.domainLookupStart),
// TCP + TLS handshake time.
connect: round(e.connectEnd - e.connectStart),
// Time-to-first-byte: request sent -> first response byte.
ttfb: round(e.responseStart - e.requestStart),
// Pure byte transfer window.
transfer: round(e.responseEnd - e.responseStart),
// Total wall-clock for the request.
total: round(e.duration),
// transferSize 0 with real decoded bytes => served from cache.
cached: e.transferSize === 0 && e.decodedBodySize > 0,
bytes: e.encodedBodySize || 0,
taoBlocked,
};
});
}
function round(n) {
return n > 0 ? Math.round(n) : 0;
}
function beaconFontTimings() {
const fonts = collectFontTimings();
if (!fonts.length) return;
const payload = JSON.stringify({ page: location.pathname, fonts, ts: Date.now() });
navigator.sendBeacon('/rum/resource-timing', payload);
}
addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') beaconFontTimings();
});
Line-by-line explanation
The filter keeps only font requests. Fonts referenced from an @font-face src report initiatorType: 'css'; fonts requested through <link rel="preload" as="font"> report link. Filtering on the URL extension catches both WOFF and WOFF2 while ignoring an optional cache-busting query string.
The four phase computations each subtract two adjacent timestamps on the entry's monotonic timeline:
domainLookupEnd - domainLookupStartis DNS resolution. It is0for a warm DNS cache or a connection reused from an earlier request.connectEnd - connectStartcovers the TCP handshake plus TLS negotiation. Also0on a reused HTTP/2 connection — which is exactly why self-hosting fonts on the document origin tends to zero out DNS and connect entirely.responseStart - requestStartis TTFB: the server's think-time plus one network round trip after the request leaves the browser.responseEnd - responseStartis the transfer window — the metric most sensitive to font size, and the one unicode-range subset loading is designed to shrink.
The cache classification uses transferSize === 0 && decodedBodySize > 0: a cache hit transfers no network bytes but still has a decoded body. taoBlocked flags the cross-origin-without-TAO case so your collector can discard meaningless zeroed phases instead of averaging them in. Finally, sendBeacon queues the payload so it survives unload, fired on the hidden visibility transition rather than unload to stay compatible with the back/forward cache.
Timeout / error-handling variant
The defensive version guards every assumption: a missing Resource Timing API, a sendBeacon that returns false (queue full or payload too large), and TAO-blocked entries whose phases must be nulled rather than reported as zero. It also caps payload size so a page with dozens of subsets cannot exceed the Beacon size budget.
Defensive capture with fallback and TAO handling
function safeBeaconFontTimings() {
if (typeof performance?.getEntriesByType !== 'function') return;
let fonts;
try {
fonts = collectFontTimings();
} catch {
return; // never let RUM throw into the page
}
if (!fonts.length) return;
// Null out phases we cannot trust on cross-origin entries.
const cleaned = fonts.map((f) =>
f.taoBlocked
? { url: f.url, transfer: null, ttfb: null, dns: null, connect: null,
total: f.total, cached: null, taoBlocked: true }
: f
);
// Cap to the 20 slowest so the payload stays well under the Beacon limit.
const top = cleaned.sort((a, b) => (b.total || 0) - (a.total || 0)).slice(0, 20);
const payload = JSON.stringify({ page: location.pathname, fonts: top, ts: Date.now() });
const sent = 'sendBeacon' in navigator &&
navigator.sendBeacon('/rum/resource-timing', payload);
if (!sent) {
// Beacon rejected (too large / unsupported): keepalive fetch survives unload.
fetch('/rum/resource-timing', {
method: 'POST',
body: payload,
keepalive: true,
}).catch(() => {});
}
}
The try/catch ensures instrumentation can never break the page it measures. Nulling TAO-blocked phases keeps your percentiles honest — a zeroed DNS phase would otherwise drag the p75 toward zero. The keepalive fetch is the documented fallback when sendBeacon returns false, and the empty .catch swallows network errors so a failed beacon stays invisible to the user.
A note on units and the timeline: every timestamp on a PerformanceResourceTiming entry is a DOMHighResTimeStamp measured in milliseconds relative to the same time origin as performance.now(), with sub-millisecond resolution that browsers deliberately coarsen for privacy. Because they share an origin you can subtract any two safely, but you should never compare them against Date.now() (a wall-clock value) — mixing the two timelines produces nonsense durations. Rounding to whole milliseconds, as the round helper does, also discards the fractional jitter that would otherwise make your percentiles noisy without adding any real precision.
Verification
Confirm the capture works before trusting the data:
- In DevTools open the Console and run
collectFontTimings(). You should get an array with one object per font, and on a fresh load (cache disabled)transferandttfbshould be non-zero. - Reload without clearing cache and re-run: entries should now show
cached: trueandtransfer: 0. This proves your cache detection works against yourCache-Controlheaders. - In the Network panel, filter to
resource-timingand trigger the beacon by switching tabs. You should see exactly one request of typeping(Beacon) with a 2xx status. - For a cross-origin CDN font, check the response headers in the Network panel for
Timing-Allow-Origin. If it is absent,collectFontTimings()will reporttaoBlocked: trueand zeroed phases — add the header on the CDN and re-verify the phases populate.
Common pitfalls
- Forgetting
Timing-Allow-Originon the CDN. Cross-origin fonts silently return zeroed DNS/connect/TTFB and atransferSizeof 0. You will think every font is cached and instantly delivered. Always set TAO on cross-origin font responses, or self-host on the document origin. - Treating
durationas transfer time.entry.durationis the full wall-clock including DNS, connect, and queueing. UseresponseEnd - responseStartfor the byte-transfer phase; conflating them blames the font size for what was really connection setup. - Reading entries too early. Calling
getEntriesByType('resource')before the font has finished loading returns a partial entry or none. Capture on the visibility/hiddentransition, or use a bufferedPerformanceObserveras shown in the parent RUM guide. - Letting the payload exceed the Beacon limit. Pages with many subsets can blow past the per-origin Beacon size budget;
sendBeaconthen returnsfalseand the data is lost. Cap the entry count and fall back to akeepalivefetch. - Counting a
transferSizeof 0 as a cache hit on cross-origin entries. Without TAO,transferSizeis forced to 0 regardless of whether the font came from the network. Gate the cache classification on!taoBlockedso you do not mislabel cross-origin network fetches as cache hits.
FAQ
Why is the transfer time zero for my Google Fonts / CDN fonts?
Because the font is cross-origin and its response lacks the Timing-Allow-Origin header. For privacy, browsers zero out the detailed phase timestamps and transferSize on cross-origin resources unless the server opts in with TAO. Either add Timing-Allow-Origin: * (or your specific origin) on the CDN response, or self-host the font on your document origin where full timing is always available.
Does the Resource Timing API work in Safari and Firefox?
Yes — PerformanceResourceTiming and getEntriesByType('resource') are supported in all current versions of Safari, Firefox, Chrome, and Edge, as is navigator.sendBeacon(). The cross-origin Timing-Allow-Origin requirement applies identically across engines. The only commonly-missing companion API is navigator.connection (Chromium-only), which you would use to tag the connection class, not to read timing.
How is this different from reading document.fonts?
document.fonts (the CSS Font Loading API) tells you about font loading state — when a FontFace reaches loaded and when document.fonts.ready resolves — which is how you measure swap delay. The Resource Timing API tells you about the network request for the font file: DNS, connect, TTFB, transfer, and cache status. RUM uses both: Resource Timing for the network breakdown, the Font Loading API for the swap window.
Related
- Real-User Monitoring for Web Fonts — the parent guide that beacons and aggregates these timings.
- Font Performance Monitoring & Auditing — the blueprint covering lab and field font measurement.
- Browser Font Caching Mechanics — the cache headers that drive your cache-hit ratio.