Using Paint Timing to Measure Font Renders
This guide is part of the Measuring Font Loading Performance topic, itself under the Font Performance Monitoring & Auditing area.
A Lighthouse score or a synthetic waterfall tells you a font took 340ms to download, but it does not tell you whether that download actually delayed the moment a user saw text. The number that matters to a real visitor is the gap between "the browser could have painted something" and "the browser painted the actual heading in the actual web font." The Paint Timing API exposes the first half of that gap — first-paint and first-contentful-paint — and when you line those timestamps up against the font's entry in the Resource Timing API, you get a precise, reproducible measurement of how much time text spent waiting on a web font rather than being padding in a spreadsheet. This page shows the exact correlation technique, a defensive variant that survives fonts that never load, and how to verify the numbers in DevTools.
Prerequisites
- A page that loads at least one custom
@font-facereferenced by visible text (not just icon glyphs). - Familiarity with the CSS Font Loading API and the
document.fontsFontFaceSet. - A baseline understanding of
font-display— see font-display Values: swap, optional, fallback Explained — since the descriptor changes which paint the font actually affects. - Chrome or Edge 60+, or Firefox 84+, for
PerformancePaintTiming; Safari addedfirst-contentful-paintin Safari 16.4, so treat it as progressively-enhanced telemetry, not a hard dependency.
Why paint timing alone is not enough
performance.getEntriesByType('paint') gives you two entries:
performance.getEntriesByType('paint');
// [
// { name: 'first-paint', startTime: 812.4 },
// { name: 'first-contentful-paint', startTime: 918.9 }
// ]
first-contentful-paint (FCP) fires the moment the browser paints the first text, image, or SVG. If your critical heading uses a self-hosted web font with the default font-display: auto or block, FCP is delayed until that font finishes downloading and is parsed by the layout engine, because the block period suppresses the text paint entirely. If you use font-display: swap, FCP happens immediately with the fallback glyph, and the font swap is a second, separate render that Paint Timing does not report at all — you need a layout-shift or a font-specific timestamp to see it. Either way, FCP alone can't tell you why it landed where it did. You need to overlay it with the font's actual network timeline.
Implementation: correlating FCP with the font resource entry
The technique: read the font's PerformanceResourceTiming entry (its responseEnd), read the FCP PerformancePaintTiming entry, and compute the delta. If FCP occurs after responseEnd, the font was very likely on the critical rendering path for that paint.
function measureFontRenderDelay(fontUrlSubstring) {
const paintEntries = performance.getEntriesByType('paint');
const fcp = paintEntries.find(e => e.name === 'first-contentful-paint');
const fontEntry = performance
.getEntriesByType('resource')
.find(e => e.initiatorType === 'css' && e.name.includes(fontUrlSubstring));
if (!fcp || !fontEntry) {
return null; // font never requested, or FCP hasn't fired yet
}
const fontReady = fontEntry.responseEnd;
const renderDelayMs = Math.max(0, fcp.startTime - fontReady);
return {
fcp: Math.round(fcp.startTime),
fontResponseEnd: Math.round(fontReady),
fontBlockedFcpBy: fcp.startTime >= fontReady ? Math.round(renderDelayMs) : 0,
fcpBeforeFontReady: fcp.startTime < fontReady,
};
}
// Run after both are likely settled
window.addEventListener('load', () => {
const result = measureFontRenderDelay('inter-var.woff2');
console.table(result);
});
Line by line: getEntriesByType('paint') retrieves the two paint marks recorded so far — call this after load so both are guaranteed to exist. getEntriesByType('resource') returns every fetched resource; filtering on initiatorType === 'css' isolates fonts requested by an @font-face rule (as opposed to a <link rel=preload>, whose initiatorType is 'link' — check both if you preload). fontEntry.responseEnd is the timestamp the last byte of the font arrived and is ready for the rasterizer. The critical comparison is fcp.startTime vs fontReady: if FCP is at or after fontReady, the font gated that paint and fontBlockedFcpBy reports the size of that gate in milliseconds. If fcpBeforeFontReady is true, the browser painted with a fallback (typically under font-display: swap or optional) and the web font swapped in later, invisibly to Paint Timing — you'd catch that swap separately via a layout-shift observer, as covered in Finding the Font Causing CLS in DevTools.
Defensive variant: fonts that never resolve or a PerformanceObserver instead of polling
Reading getEntriesByType after load works for a one-off audit but misses failures: a font that 404s, or a page where load fires before the paint entries exist on a slow device. A PerformanceObserver buffered on both paint and resource avoids the race and lets you time out gracefully:
function observeFontRenderDelay(fontUrlSubstring, { timeoutMs = 8000 } = {}) {
return new Promise((resolve) => {
let fcpEntry = null;
let fontEntry = null;
let settled = false;
const tryResolve = () => {
if (settled || !fcpEntry) return;
// Font entry may legitimately never arrive (404, blocked, or swap-only)
settled = true;
resolve({
fcp: Math.round(fcpEntry.startTime),
fontResponseEnd: fontEntry ? Math.round(fontEntry.responseEnd) : null,
fontFoundBeforeTimeout: Boolean(fontEntry),
});
observer.disconnect();
};
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.entryType === 'paint' && entry.name === 'first-contentful-paint') {
fcpEntry = entry;
}
if (entry.entryType === 'resource' && entry.name.includes(fontUrlSubstring)) {
fontEntry = entry;
}
}
tryResolve();
});
try {
observer.observe({ type: 'paint', buffered: true });
observer.observe({ type: 'resource', buffered: true });
} catch {
resolve(null); // PerformanceObserver or buffered flag unsupported
return;
}
setTimeout(() => {
if (!settled) {
settled = true;
resolve({ fcp: fcpEntry ? Math.round(fcpEntry.startTime) : null, fontResponseEnd: null, timedOut: true });
observer.disconnect();
}
}, timeoutMs);
});
}
The buffered: true option is the key defensive detail: without it, an observer created after the paint already happened would simply never see it, silently under-reporting every page where your script initializes late. The setTimeout guard ensures a font blocked by an ad-blocker or a CORS misconfiguration (see Serving Fonts from a CDN with Correct CORS) resolves the promise instead of hanging your RUM beacon indefinitely.
Verification
- Open DevTools → Performance panel, record a reload, and expand the Timings track —
FCPappears as a labeled diamond. - In the Network panel, find the font request and note its
Finishtime relative to the same timeline origin (navigationStart). - In the Console, run
performance.getEntriesByType('paint')andperformance.getEntriesByType('resource').filter(e => e.name.includes('.woff2'))and manually confirmresponseEndvsfirst-contentful-paint.startTimematches what the Performance panel showed within a few milliseconds. - For field data, wire either function above into your RUM pipeline (see Real-User Monitoring for Web Fonts) and bucket
fontBlockedFcpByinto a histogram — a p75 above roughly 200ms on afont-display: blockpage is a strong signal to switch toswapor preload the font, a pattern detailed in Preload Fonts Without Blocking LCP.
Interpreting the numbers across font-display strategies
The same measurement code produces very different meaning depending on the font-display descriptor in effect, so always report font-display alongside the delay metric or the aggregate is meaningless.
font-display |
Typical fcp relative to fontResponseEnd |
What the metric tells you |
|---|---|---|
auto / block |
FCP occurs at or after fontResponseEnd |
fontBlockedFcpBy is a direct measurement of render-blocking time — the number you want to drive to zero |
swap |
FCP occurs before fontResponseEnd |
fcpBeforeFontReady is true; FCP is unaffected, but a later swap (and possible CLS) is happening off-screen from this metric |
optional |
FCP occurs before fontResponseEnd, and the web font may never apply this navigation |
Same as swap, but the swap may not happen at all if the font isn't already cached — track a separate "font applied" boolean via document.fonts.check() |
fallback |
A short (~100ms) block window, then swap-or-give-up | fontBlockedFcpBy will show a small, roughly constant value near 100ms even on fast connections — that's the block period, not network latency |
This table is the reason a single aggregate number ("average font render delay: 140ms") across a fleet of pages using mixed font-display values is close to useless: you're averaging a render-blocking measurement with a value that's structurally supposed to be near zero. Segment every report by the font-display value in force for that font, ideally read directly from getComputedStyle or the CSSOM rather than assumed from source, since a CDN or a framework's font-optimization step can silently override it (this is exactly what next/font does — see Next.js Font Optimization with next/font).
Aggregating across navigations for a real RUM signal
A single-page reading is useful for local debugging, but the actionable version of this metric lives in aggregate, bucketed by connection type and device class. A minimal reporting shape:
function reportFontRenderDelay(fontUrlSubstring, sendBeacon) {
observeFontRenderDelay(fontUrlSubstring, { timeoutMs: 6000 }).then((result) => {
if (!result) return;
sendBeacon('font-paint-timing', {
fcp: result.fcp,
fontBlockedFcpBy: result.fontBlockedFcpBy ?? 0,
fcpBeforeFontReady: Boolean(result.fcpBeforeFontReady),
connection: navigator.connection?.effectiveType ?? 'unknown',
timedOut: Boolean(result.timedOut),
});
});
}
Bucket the resulting fontBlockedFcpBy values by connection.effectiveType ('4g', '3g', '2g') — a font that adds an imperceptible 20ms delay on a cabled desktop can add 600ms or more on a throttled '3g' connection because the font request competes with the HTML and critical CSS for the same limited bandwidth. Report the p50 and p75 separately; a p50 of 50ms with a p75 of 400ms means a meaningful slice of your traffic — often mobile users on congested networks — is experiencing a much worse render delay than your median dashboard suggests. This is the same segmentation discipline used for Core Web Vitals generally, and it applies with extra force to fonts because font requests are disproportionately sensitive to round-trip time: a font is typically requested only after the CSSOM is built, so it inherits every millisecond of latency from the HTML and CSS that preceded it, plus its own DNS/TLS/TTFB if it's on a different origin from Google Fonts vs Self-Hosting.
Common Pitfalls
- Reading paint entries before
loadfires. On a throttled connection, FCP can occur after your script'sDOMContentLoadedhandler runs, sogetEntriesByType('paint')returns an empty array and every downstream calculation silently becomesnull. Fix: gate the read onload, or use the bufferedPerformanceObserverpattern above. - Matching the wrong resource entry.
initiatorTypediffers between a font loaded via@font-face('css') and one loaded via<link rel=preload as=font>('link'). Filtering only on'css'on a preloaded page returnsundefinedeven though the font clearly downloaded — check both initiator types or match on filename alone. - Ignoring cache hits. A font served from the HTTP cache reports
transferSize: 0and a near-zeroresponseEndrelative tostartTime, which will makefontBlockedFcpBylook artificially small on repeat views. Segment your reporting bytransferSize > 0to separate cold-cache from warm-cache visits, matching the approach in Cache-Control: immutable for Long-Lived Font Files. - Conflating FCP delay with the font swap. Under
font-display: swap, FCP is unaffected by the font — the fallback paints on time — but the swap into the web font happens later and shows up only as alayout-shiftentry or a manualdocument.fonts.readytimestamp, not in Paint Timing. Treat "FCP delay from fonts" and "swap-induced CLS" as two separate metrics you track independently. - Assuming Safari support.
first-contentful-paintonly landed in Safari 16.4 (March 2023); on earlier Safari versionsgetEntriesByType('paint')returns onlyfirst-paintor an empty array. Feature-detect before reporting, and fall back todocument.fonts.ready.then(...)timestamps for that cohort.
Frequently Asked Questions
Does first-paint or first-contentful-paint matter more for fonts?
Use first-contentful-paint for font analysis. first-paint can fire on a background-color change or a paint with no text at all, so it tells you nothing about whether your web font was involved. first-contentful-paint specifically requires actual text, image, or SVG content, making it the correct anchor for "how long did the user wait to see text."
Can I use the Paint Timing API to catch a font swap after FCP?
Not directly — Paint Timing only records the first paint of each type, so a later font swap under font-display: swap doesn't generate a new paint entry. Pair this technique with a layout-shift PerformanceObserver (see Track Font Load Time with PerformanceObserver) or document.fonts.ready to capture the swap moment itself.
How is this different from measuring FOUT duration directly? FOUT duration measures the visible span between the fallback render and the web font swap — a UX symptom. Paint-timing correlation measures whether the font gated the first paint at all, which is a stricter, LCP-adjacent signal. Both are useful together; see Measuring FOUT Duration in the Field for the complementary technique.