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 blueprint. Here we narrow to a single job: continuously observe network resource entries, pick out the font files, and compute each one's transfer time so you can report it to a Real User Monitoring (RUM) backend.

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

Prerequisites

  • Fonts served as WOFF2 (the \.woff2 filter below assumes it; adjust if you still serve WOFF/TTF).
  • For accurate transferSize, cross-origin font responses must send a Timing-Allow-Origin header. 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.

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

PerformanceObserver buffered font flow Buffered past entries and live streamed entries both pass through a font filter into a RUM beacon. buffered past entries live streamed entries isFontEntry filter sendBeacon → RUM
buffered: true folds already-loaded fonts back into the same reporting path.

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.

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.

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

FAQ

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. 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 the field-measurement guide linked below.

Related