Fixing Layout Shifts Caused by Dynamically Injected Web Fonts

June 23, 2026 10 min read 3 views

Your page looks great in the design tool, but in the browser the text jumps a half-line the moment the custom font loads. That jump is Cumulative Layout Shift, and web fonts are one of its most common causes. The problem gets significantly worse when a script β€” an A/B test, a tag manager, a widget β€” injects the <link> or @font-face rule after the initial HTML parse.

What You'll Learn

  • Why dynamic font injection amplifies layout shift compared to static font loading
  • How to find and measure font-driven CLS in DevTools and field data
  • Which font-display value actually fits your situation
  • How to preload fonts and use CSS metric overrides to neutralize swap jank
  • How to control injection timing in JavaScript when you cannot change the third-party code

Why Fonts Cause Layout Shifts

The browser renders text immediately using a fallback font β€” usually a system serif or sans-serif. When the web font file finally arrives, the browser swaps it in. If the two fonts have different metrics (cap height, line height, average character width), every text block reflowing into the new metrics pushes elements below it up or down. That movement is recorded as CLS.

The later the font arrives, the larger the shift, because more content has already been painted at fallback metrics. A font loaded from a <link rel="preload"> in <head> arrives early enough that the visible shift may be imperceptible. A font injected after DOMContentLoaded arrives so late that several full paragraphs have already been measured and positioned by the layout engine.

How Dynamic Font Injection Makes Things Worse

Static font loading means the browser discovers the <link> or @font-face rule during the initial HTML parse. Dynamic injection β€” adding a <style> tag via document.createElement, appending a <link> tag in a load handler, or letting a third-party script fire a font request β€” pushes font discovery to the end of the critical path or beyond it.

The browser's preload scanner, which fetches resources speculatively while the HTML parser is still running, cannot see resources that do not exist in the raw HTML. That means the font file request does not even start until the injecting script executes, which can be hundreds of milliseconds after first paint.

Common sources of dynamic injection include Google Tag Manager custom HTML tags, chat widgets, consent management platforms that load fonts after a consent decision, and client-side A/B testing tools that swap a heading font to test a new brand direction.

Diagnosing Font-Driven CLS

Before you fix anything, confirm that fonts are actually causing your CLS. Open Chrome DevTools, go to the Performance panel, and record a page load. Look for a purple Layout bar that appears roughly when a font finishes loading. The Experience row will show Layout Shift records β€” click one and inspect the "Moved from" and "Moved to" rectangles to confirm they cover text nodes.

The Rendering panel has a "Layout Shift Regions" overlay that flashes shifted elements in blue in real time. Enable it and reload the page. If your text blocks flash, fonts are the likely cause.

For field data, check the Core Web Vitals report in Google Search Console and filter by page. The Chrome User Experience Report (CrUX) can tell you whether shifts are concentrated at a specific percentile that maps to the font swap timing. The Web Vitals extension for Chrome also logs each shift to the console with a stack trace showing which element moved.

In the Network panel, filter by Font type and look at the Waterfall. A font that starts downloading well after the page is visually complete β€” especially if its initiator is a script rather than the parser β€” is almost certainly causing late swap shifts.

The font-display Property and When to Use Each Value

The font-display descriptor inside @font-face controls the swap timeline. Choosing the wrong value is the single most common font CLS mistake.

swap

font-display: swap gives a very short block period (usually ~100 ms) and an infinite swap period. Text is visible almost immediately in the fallback font, and the web font swaps in whenever it arrives. This is the option Google Fonts appends by default. It prevents invisible text but does nothing to reduce the visual shift when the web font finally loads.

optional

font-display: optional gives a very short block period and no swap period. If the font has not loaded within that tiny window, the browser commits to the fallback for that page view. On the second visit the font is cached and used immediately, so there is zero shift. For body text this is often the best CLS option, accepting that first-time visitors see the fallback.

fallback

font-display: fallback is a middle ground: a very short block period and a short swap window (roughly 3 seconds). If the font loads within that window it swaps; if not, the fallback is used for the session. It balances brand fidelity against CLS better than swap for most use cases.

block

font-display: block hides text for up to 3 seconds while waiting for the font. It causes Flash of Invisible Text (FOIT) and contributes to poor LCP. Avoid it for body text; it is appropriate only for icon fonts where fallback characters would be meaningless.

@font-face {
  font-family: 'InterDisplay';
  src: url('/fonts/InterDisplay-Regular.woff2') format('woff2');
  font-weight: 400;
  font-style: normal;
  font-display: optional; /* best for CLS on body text */
}

Preloading Fonts to Eliminate Late Swaps

Preloading tells the browser to fetch the font file at the highest priority, before it has parsed any CSS that references it. If the font arrives before first paint, there is no fallback text painted and therefore no shift.

<link
  rel="preload"
  href="/fonts/InterDisplay-Regular.woff2"
  as="font"
  type="font/woff2"
  crossorigin="anonymous"
/>

The crossorigin="anonymous" attribute is mandatory even for same-origin fonts β€” the browser fetches fonts in a CORS context, and omitting it causes a double download. This is a well-known gotcha that silently wastes bandwidth without triggering any visible error. If you encounter other silent failures in production, the pattern of issues that are hard to reproduce locally is a common theme β€” similar to the kind of problem covered in debugging CORS errors that only appear in production deployments.

Preloading only helps for fonts you control and know you will use. Preloading a font that does not end up being used wastes bandwidth and competes with other critical resources. Preload only the subset of weights and styles that are visible above the fold on initial load.

Using size-adjust and Font Metric Overrides

Even with a preload, a swap will still happen if the font loads after any text paint. The best way to minimize the visual impact of that swap is to make the fallback font's metrics as close as possible to the web font's metrics. CSS @font-face descriptors let you adjust fallback fonts directly.

@font-face {
  font-family: 'InterDisplay-Fallback';
  src: local('Arial');
  size-adjust: 107%;
  ascent-override: 90%;
  descent-override: 22%;
  line-gap-override: 0%;
}

body {
  font-family: 'InterDisplay', 'InterDisplay-Fallback', sans-serif;
}

The values above are illustrative β€” you will need to calculate the right percentages for your specific web font. The Fontaine tool (an npm package) and the Font style matcher web tool both automate this calculation by comparing metrics between your web font and common system fonts. Even an approximate match that gets the line height within a few percent can reduce your CLS score dramatically.

The size-adjust descriptor scales the entire glyph set up or down so that the fallback occupies roughly the same space as the web font. The ascent-override, descent-override, and line-gap-override descriptors control the vertical metrics that determine line height. Together they shrink the metric gap that causes reflowing.

Controlling Injection Timing with JavaScript

Sometimes you cannot change the font declaration itself β€” it comes from a third-party script or a CMS. In that case, control when the injection happens so the browser has as little painted content as possible when the swap occurs.

Defer the injecting script

If a script is injecting a font, load that script with defer or move it to just before </body>. This does not prevent the shift, but it ensures the shift happens after the page is interactive rather than mid-paint, which the user perceives as less jarring. The CLS measurement window in the browser is 5 seconds from the first user input or 5 seconds from navigation; a shift after the user has started interacting is not counted in the CLS score.

Use the Font Loading API to gate rendering

The CSS Font Loading API lets you wait for a specific font before revealing content, without hiding the entire page.

const font = new FontFace(
  'InterDisplay',
  'url(/fonts/InterDisplay-Regular.woff2)',
  { weight: '400', style: 'normal' }
);

font.load().then((loadedFont) => {
  document.fonts.add(loadedFont);
  document.documentElement.classList.add('fonts-loaded');
});

Then in CSS, apply the web font only once the class is present:

body {
  font-family: Arial, sans-serif; /* fallback */
}

.fonts-loaded body {
  font-family: 'InterDisplay', Arial, sans-serif;
}

This pattern, sometimes called FOUT with a class, gives you explicit control over exactly when the swap happens. Pair it with the metric overrides from the previous section and the shift becomes nearly invisible.

Cache the font decision in sessionStorage

On repeat visits the font will be in the browser cache and will load before first paint. You can skip the fallback class entirely on repeat visits:

if (sessionStorage.getItem('fonts-loaded')) {
  document.documentElement.classList.add('fonts-loaded');
} else {
  const font = new FontFace(
    'InterDisplay',
    'url(/fonts/InterDisplay-Regular.woff2)'
  );
  font.load().then((loaded) => {
    document.fonts.add(loaded);
    document.documentElement.classList.add('fonts-loaded');
    sessionStorage.setItem('fonts-loaded', '1');
  });
}

This reduces perceived jank for most of your returning visitors, who make up the majority of your traffic, without adding complexity to the first-visit path.

Common Pitfalls and Gotchas

Preloading unused font weights. It is tempting to preload every weight you use anywhere on the site. Only preload fonts visible in the initial viewport. Preloading a bold weight that only appears below the fold delays critical resources.

Mixing font-display values across faces. If you declare font-display: swap on the regular weight but font-display: block on the bold weight, bold text will be invisible during load while regular text is already visible. Align the strategy across all weights in a font family. This is the same kind of inconsistency that causes hard-to-trace visual bugs β€” much like CSS specificity conflicts that only surface in production.

Self-hosting without subsetting. Self-hosting gives you control over caching headers and preload, but if you serve the full Unicode range of a Latin font you are sending far more bytes than needed. Use a tool like pyftsubset or Fonttools to strip glyphs your content will never use.

Variable fonts with a large swap jump. Variable fonts can cover the full weight range in one file, which is good for performance. But if your fallback is a static font at a different weight axis position, the swap visual difference can be larger than expected. Test your specific fallback/variable font pairing in the Font style matcher before shipping.

Ignoring the swap on mobile. Mobile connections are slower, so font files arrive later, and shifts are more pronounced. Always test your font loading strategy on a throttled connection (Chrome DevTools β†’ Network β†’ Slow 4G) before considering a fix complete.

Wrapping Up: Next Steps

Font-driven layout shift is solvable with a small set of targeted techniques. Here is a concrete action list:

  1. Measure first. Use the Performance panel and the Web Vitals extension to confirm fonts are actually causing your CLS before changing anything.
  2. Add a <link rel="preload"> for every above-the-fold font, with crossorigin="anonymous", and verify in the Network waterfall that the font request moves to the top.
  3. Set font-display: optional or fallback on your @font-face declarations. Swap swap out for one of these unless brand font is critical on first view.
  4. Add metric overrides to your fallback font using size-adjust, ascent-override, and descent-override. Use Fontaine or the Font style matcher to calculate the values.
  5. For third-party injected fonts, use the Font Loading API with a fonts-loaded class gate and cache the result in sessionStorage for repeat visits.

Taken together, these steps typically bring font-related CLS from a "poor" rating down to near zero on both first and subsequent visits. The techniques here complement broader performance work β€” if you are also dealing with other production-only surprises, it is worth reviewing how silent failures in browser APIs surface unexpectedly, as explored in why structuredClone fails silently on certain value types.

Frequently Asked Questions

Does adding font-display swap actually fix cumulative layout shift from web fonts?

Not on its own. font-display: swap prevents invisible text but does not reduce the visual shift when the font swaps in. To fix CLS you also need to either preload the font so it arrives before first paint, or use CSS metric overrides on the fallback font to minimize the difference between fallback and web font metrics.

How do I stop a third-party script from causing a font layout shift I can't control?

Use the CSS Font Loading API to gate your font-family assignment behind a class that is only added after the font loads. This gives you programmatic control over the swap timing regardless of how the font was injected. Pair it with fallback font metric overrides to minimize the visual impact of the swap.

What is the best font-display value to minimize CLS on body text?

font-display: optional is the best choice for CLS on body text. It commits to the fallback if the font has not loaded within a very short window, meaning no swap and no shift. The trade-off is that first-time visitors may see a system font, but repeat visitors with a cached font see the web font immediately with zero shift.

Why does preloading a web font sometimes cause a double download?

Omitting the crossorigin attribute from the preload link tag causes a double download. The browser fetches fonts in a CORS context, so the preload and the actual font request use different credential modes and are treated as separate resources. Always include crossorigin="anonymous" on font preload tags, even for same-origin fonts.

How do size-adjust and ascent-override help with font layout shift?

These CSS font-face descriptors let you scale and reposition a fallback font's glyphs to match the dimensions of your web font. When the fallback occupies almost the same space as the web font, the swap causes little or no reflowing of surrounding content, which directly lowers the CLS score.

πŸ“€ Share this article

Sign in to save

Comments (0)

No comments yet. Be the first!

Leave a Comment

Sign in to comment with your profile.

πŸ“¬ Weekly Newsletter

Stay ahead of the curve

Get the best programming tutorials, data analytics tips, and tool reviews delivered to your inbox every week.

No spam. Unsubscribe anytime.