Track Font Load Time with PerformanceObserver
This how-to is one technique inside Measuring Font Loading Performance, part of the wider Font Performance Monitoring & Auditing area. Here we narrow to a single job: continuously observe network resource entries, pick out the font files, compute each one's transfer time, and get that number into a Real User Monitoring (RUM) backend without gaps.
Problem Statement
A one-shot performance.getEntriesByType('resource') call only sees entries that already exist at the instant you call it. Fonts requested later — by a lazily mounted component, a route change, or a late @font-face match — are missed, and entries that fired before your analytics script loaded are missed too. PerformanceObserver solves both: it streams entries as they complete and, with buffered: true, replays the ones that happened before the observer was even created.
This matters more than it first appears. The browser's performance buffer has a finite capacity (commonly 250 resource entries) and can be cleared with performance.clearResourceTimings(), so on a busy page the very font entry you care about may already have been evicted by the time a deferred analytics bundle runs its one-shot query. An observer registered early in the <head> sidesteps the race entirely: it is listening before the first font request even completes, and the buffered replay covers the narrow window between page start and observer registration. The result is a single code path that captures fonts loaded early, fonts loaded late, and fonts served from cache — without you having to reason about when your reporting code happened to execute.
It is worth being explicit about what this technique measures and what it does not. Resource Timing entries describe the network transaction only — request start through last byte received. They say nothing about when the browser actually swapped the fallback glyphs for the web font, which is a separate, later event governed by FontFaceSet.ready and the rendering pipeline. Conflating "font finished downloading" with "font finished rendering" is a common mistake; keep the two concerns in separate metrics so a slow-parsing large variable font doesn't get misdiagnosed as a slow network problem.
Prerequisites
- Fonts served as WOFF2 (the
\.woff2filter below assumes it; adjust the regex if you still serve WOFF or TTF, or if you ship a variable font under a non-standard extension). - For accurate
transferSize,requestStart, andresponseStart, cross-origin font responses must send aTiming-Allow-Originheader — this is the same header required when serving fonts from a CDN with CORS. Self-hosted same-origin fonts need nothing extra. - A browser that supports
PerformanceObserverwith theresourceentry type (Chrome 52+, Firefox 57+, Safari 11+). The fallback variant below covers anything older. - A RUM ingestion endpoint that accepts a small JSON POST body via
sendBeacon(orfetchwithkeepalive: trueas an alternative transport, covered below).
Implementation
Register the observer as early as possible — ideally inline in the <head> so it is live before any font request resolves.
Primary: observe resource entries, filter to fonts, report transfer time
function isFontEntry(entry) {
return entry.initiatorType === 'css' || /\.woff2?($|\?)/.test(entry.name);
}
function reportFont(entry) {
const transferMs = entry.responseEnd - entry.responseStart; // download window
const ttfbMs = entry.responseStart - entry.requestStart; // server wait
const totalMs = entry.responseEnd - entry.fetchStart; // discovery → last byte
const payload = {
name: entry.name.split('/').pop(),
transferMs: +transferMs.toFixed(1),
ttfbMs: +ttfbMs.toFixed(1),
totalMs: +totalMs.toFixed(1),
bytes: entry.transferSize || entry.encodedBodySize || null,
cached: entry.transferSize === 0 && entry.decodedBodySize > 0,
};
navigator.sendBeacon?.('/rum/font-load', JSON.stringify(payload));
}
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (isFontEntry(entry)) reportFont(entry);
}
});
observer.observe({ type: 'resource', buffered: true });
Annotated explanation
isFontEntrymatches two ways a font surfaces in the resource list. A WOFF2 fetched directly from an@font-facesrcusually hasinitiatorType === 'css'; matching the filename extension as well catches fonts pulled by preload or by a font loader script.responseEnd - responseStartis the pure download window — the bytes on the wire. This is the number that shrinks when you subset a font with tools discussed in Unicode-Range Subset Loading, so it is the most actionable single metric for payload work.responseStart - requestStartisolates server/connection wait (time to first byte). A large value here points at a slow origin or a cold connection, not a fat file.responseEnd - fetchStartis the wall-clock cost of the whole request. If this is much larger than the download window, the font was discovered late and wants a resource hint.cachedis inferred: atransferSizeof0alongside a non-zerodecodedBodySizemeans the bytes came from cache, not the network. Reporting this lets you separate first-view from repeat-view timings in RUM.{ type: 'resource', buffered: true }is the critical pairing.buffered: truemakes the browser immediately deliver every resource entry already in the performance buffer, so you capture fonts that loaded before this code ran.
A word on initiatorType values you will encounter: a font pulled directly by an @font-face src reports css, a font fetched via <link rel="preload" as="font"> reports link, and a font requested by a JavaScript font loader (or fetch) reports fetch or script. Because the initiator varies with how you load the font, the filename-extension test in isFontEntry is the reliable common denominator — it catches the file regardless of which mechanism kicked off the request. Keep both checks: the initiatorType === 'css' branch is a fast path for the common case, and the regex is the safety net for everything else.
It is also worth understanding what transferMs does and does not include. The responseStart-to-responseEnd window is body download only; it excludes DNS, TCP, and TLS, which live earlier in the entry (domainLookupStart through connectEnd). That separation is intentional and useful: if you want to know whether a font is slow because the file is big or because the connection was cold, compare transferMs against connectEnd - fetchStart. A self-hosted, already-warm-connection font should show near-zero connection cost and a transferMs that tracks file size almost linearly, which is exactly the clean signal you want when validating a subsetting change.
Worked Example: Comparing Two Fonts on the Same Page
Suppose a page loads two families — a subsetted Inter variable font (48 KB) preloaded in the <head>, and an unsubsetted NotoSansJP (280 KB) discovered only when a CJK-locale route mounts. Both pass through the same reportFont function above, but their entries tell very different stories:
[
{ "name": "inter-var.woff2", "transferMs": 9.2, "ttfbMs": 4.1, "totalMs": 14.8, "bytes": 48213, "cached": false },
{ "name": "notosansjp.woff2", "transferMs": 187.4, "ttfbMs": 6.3, "totalMs": 640.9, "bytes": 287510, "cached": false }
]
inter-var.woff2 has a totalMs (14.8ms) close to its transferMs (9.2ms) — it was discovered essentially immediately, which is what you expect from a preloaded font. notosansjp.woff2 shows the opposite pattern: totalMs (640.9ms) is more than three times transferMs (187.4ms), meaning over 450ms elapsed between fetchStart and the request actually beginning to download — almost certainly late discovery inside a route-mounted @font-face rule with no preload hint. That gap, not the file's own 187ms download, is the number worth fixing first, and the fix is the same resource-hint pattern used for the Inter font: a conditional <link rel="preload"> inserted once the router knows the locale.
Aggregating these payloads server-side (or client-side before a single batched sendBeacon) lets you compute a p75 transferMs per font family per week — the metric you graph in a dashboard and gate in CI via Lighthouse font budget checks. Batching matters here: sendBeacon payloads are capped (commonly 64 KB), so a page with a dozen web fonts should collect entries in an array and flush once on visibilitychange, rather than firing one beacon per font.
const queue = [];
function queueFont(entry) {
queue.push({
name: entry.name.split('/').pop(),
transferMs: +(entry.responseEnd - entry.responseStart).toFixed(1),
totalMs: +(entry.responseEnd - entry.fetchStart).toFixed(1),
});
}
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden' && queue.length) {
navigator.sendBeacon('/rum/font-load-batch', JSON.stringify(queue));
queue.length = 0;
}
});
Flushing on visibilitychange rather than beforeunload is deliberate: beforeunload is unreliable on mobile Safari and disables the back-forward cache, while visibilitychange fires reliably on tab switch, app backgrounding, and navigation alike.
Error Handling and Fallback Variant
PerformanceObserver and the resource type are widely available, but a defensive implementation feature-detects and degrades to a one-shot getEntriesByType read. It also guards against observe() throwing on engines that do not recognize the entry type.
Defensive: feature-detect, then fall back to getEntriesByType
function trackFontTiming(report) {
const supportsObserver =
'PerformanceObserver' in window &&
PerformanceObserver.supportedEntryTypes?.includes('resource');
if (supportsObserver) {
try {
const obs = new PerformanceObserver((list) => {
list.getEntries().filter(isFontEntry).forEach(report);
});
obs.observe({ type: 'resource', buffered: true });
return obs; // caller may disconnect() on SPA teardown
} catch (err) {
// observe() rejected the options object — drop through to the fallback.
}
}
// Fallback: replay whatever is already in the buffer once the page settles.
if ('performance' in window && performance.getEntriesByType) {
const drain = () =>
performance.getEntriesByType('resource').filter(isFontEntry).forEach(report);
if (document.readyState === 'complete') drain();
else window.addEventListener('load', drain, { once: true });
}
return null;
}
trackFontTiming(reportFont);
This variant returns the observer so a single-page app can call disconnect() on route teardown, prevents an unhandled exception on browsers that reject the options object, and still reports something useful when no observer exists by draining the buffer after load. Note the fallback cannot see fonts requested after it drains — that is precisely the gap the observer closes.
Edge case: the 250-entry buffer ceiling
On a resource-heavy page — dozens of images, scripts, and stylesheets ahead of the fonts in document order — the default 250-entry resource buffer can fill before your font requests even fire, silently dropping the oldest entries. Two defenses:
- Call
performance.setResourceTimingBufferSize(500)(or higher) as early as possible in the<head>, before other scripts run. This is cheap and has no user-visible cost. - Prefer the live-streaming half of the observer over the buffered replay for high-traffic pages: once
observe()is registered, new entries arrive via the callback regardless of buffer size, so only the pre-registration window depends on the buffer. Registering the observer as the very first script in<head>minimizes that window to milliseconds.
A third option — listening for the resourcetimingbufferfull event and calling performance.clearResourceTimings() — works but is a trap for font tracking specifically: clearing the buffer discards entries your own observer callback has not yet drained if it fires in the same tick. Prefer raising the buffer size over clearing it.
Verification
- Open DevTools and the Console, then paste the primary snippet (or load a page that includes it) with Disable cache checked in the Network panel.
- Reload. You should see one
sendBeaconcall per font in the Network panel filtered to Fetch/XHR, targeting/rum/font-load. Inspect each request payload. - Cross-check the reported
transferMsagainst the Network panel's font row: hover the WOFF2 entry, read Content Download from the timing tooltip, and confirm it matches yourtransferMswithin a millisecond or two. - Reload a second time with cache enabled. The new beacon should report
cached: trueand a near-zerotransferMs, proving the buffered observer caught the cached read. - For the batched variant, switch tabs (triggering
visibilitychange) and confirm a single/rum/font-load-batchrequest fires with an array containing every font loaded so far, rather than one request per font. - In the Performance panel, record a trace and confirm each font's Resource Timing bar aligns with the
transferMs/totalMsyou computed — the trace is the ground truth if the two ever disagree.
Common Pitfalls
- Omitting
buffered: true. Without it, any font that finished before the observer registered is invisible — and those are usually your fastest, most-cached, highest-volume sessions, so you bias the whole dataset slow. - Trusting
transferSizeon cross-origin CDNs. Without aTiming-Allow-Originheader,transferSize,responseStart, andrequestStartare all0, makingttfbMsandbytesmeaningless. Detect the zero and either self-host or set the header. - Reporting every resource, not just fonts. Skipping the
isFontEntryfilter floods your RUM endpoint with images and scripts. Always filter before the beacon. - Forgetting to
disconnect()in a SPA. A long-lived observer registered on every route adds duplicate listeners and leaks. Disconnect on teardown, or register once at app boot. - Calling
sendBeaconwith an object.navigator.sendBeaconneeds a string orBlob; passJSON.stringify(payload), not the raw object, or the body silently serializes to[object Object]. - Firing one beacon per font on a page with many families. Beyond a handful of fonts this creates request overhead that dwarfs the signal; batch into an array and flush on
visibilitychangeinstead. - Letting the 250-entry buffer fill before fonts are requested. On asset-heavy pages, raise
setResourceTimingBufferSizeor register the observer as the very first inline script, before the buffer can fill on font entries specifically.
Frequently Asked Questions
Does buffered: true work in Safari?
Yes, for the resource entry type in Safari 11+. The safest pattern is the PerformanceObserver.supportedEntryTypes check in the fallback variant, which confirms support before calling observe() and avoids a thrown exception on the handful of engines that predate it.
How is this different from just calling getEntriesByType('resource') once?
The one-shot call is a snapshot — it misses fonts requested after the call and, depending on script timing, fonts requested before it if the buffer already evicted them. The observer streams new font entries as they complete and, with buffering, replays the earlier ones, so you get the complete set regardless of when your code runs.
Can I use this to measure the visible font swap, not just the download?
No — resource timing ends at responseEnd, before the browser parses the face and reflows. To capture the user-perceived swap you also need document.fonts.ready and a paint baseline; see Measuring FOUT Duration in the Field and Using Paint Timing to Measure Fonts for that layer.
Should I use fetch with keepalive: true instead of sendBeacon?
sendBeacon is purpose-built for this: it is fire-and-forget, survives page unload, and the browser handles retry semantics for you. fetch with keepalive: true works too and gives you response inspection if your endpoint ever needs to talk back, but most RUM pipelines don't need that — reserve it for cases where sendBeacon's payload cap (commonly 64 KB) is a real constraint, since fetch keepalive requests share a similar but sometimes more generous limit depending on the browser.
Does this technique work the same way for a font loaded via the CSS Font Loading API instead of a plain @font-face rule?
Yes — a font requested through FontFace and document.fonts.add() still triggers an underlying network fetch that Resource Timing records identically; only the initiatorType differs (typically fetch or script instead of css), which is exactly why isFontEntry's extension-based fallback exists.