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 \.woff2 filter 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, and responseStart, cross-origin font responses must send a Timing-Allow-Origin header — 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 PerformanceObserver with the resource entry 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 (or fetch with keepalive: true as an alternative transport, covered below).
PerformanceObserver resource Support Compatibility matrix for PerformanceObserver with the resource entry type and buffered flag across browsers. PerformanceObserver resource Support Chrome Firefox Safari resource entries 52+ 57+ 11+ buffered: true 52+ 57+ 11+ supportedEntryTyp… 76+ 84+ 11.1+
Buffered resource observation is available in every evergreen engine; older engines need the fallback.

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

  • isFontEntry matches two ways a font surfaces in the resource list. A WOFF2 fetched directly from an @font-face src usually has initiatorType === 'css'; matching the filename extension as well catches fonts pulled by preload or by a font loader script.
  • responseEnd - responseStart is 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 - requestStart isolates 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 - fetchStart is 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.
  • cached is inferred: a transferSize of 0 alongside a non-zero decodedBodySize means 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: true makes 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.

Buffered PerformanceObserver Flow Buffered past entries and live streamed entries both pass through a font filter into a RUM beacon. Buffered PerformanceObserver Flow 1 Register observer in <head>, buffered:true 2 Replay buffered entries pre-registration fonts 3 Stream live entries later font requests 4 isFontEntry filter css or .woff2 5 sendBeacon → /rum/font-load
buffered: true folds already-loaded fonts back into the same reporting path.

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.

Inter vs NotoSansJP Timing Breakdown Bar chart comparing transfer time and total time for two fonts, one preloaded and one discovered late. Inter vs NotoSansJP Timing Breakdown Inter transferMs 9.2ms Inter totalMs 14.8ms NotoSansJP transferMs 187.4ms NotoSansJP totalMs 640.9ms milliseconds
A preloaded font's total time tracks its download; a late-discovered font's does not.

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:

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

  1. 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.
  2. Reload. You should see one sendBeacon call per font in the Network panel filtered to Fetch/XHR, targeting /rum/font-load. Inspect each request payload.
  3. Cross-check the reported transferMs against the Network panel's font row: hover the WOFF2 entry, read Content Download from the timing tooltip, and confirm it matches your transferMs within a millisecond or two.
  4. Reload a second time with cache enabled. The new beacon should report cached: true and a near-zero transferMs, proving the buffered observer caught the cached read.
  5. For the batched variant, switch tabs (triggering visibilitychange) and confirm a single /rum/font-load-batch request fires with an array containing every font loaded so far, rather than one request per font.
  6. In the Performance panel, record a trace and confirm each font's Resource Timing bar aligns with the transferMs/totalMs you 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 transferSize on cross-origin CDNs. Without a Timing-Allow-Origin header, transferSize, responseStart, and requestStart are all 0, making ttfbMs and bytes meaningless. Detect the zero and either self-host or set the header.
  • Reporting every resource, not just fonts. Skipping the isFontEntry filter 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 sendBeacon with an object. navigator.sendBeacon needs a string or Blob; pass JSON.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 visibilitychange instead.
  • Letting the 250-entry buffer fill before fonts are requested. On asset-heavy pages, raise setResourceTimingBufferSize or 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.

Related