← Back to blog

Technical SEO · July 9, 2026 · 8 min read

Cumulative Layout Shift on Mobile: The 5 Ad and Font Patterns That Wreck CLS

Mobile CLS failures traced to 5 specific patterns: unsized ad slots, font metric mismatches, lazy images, injected banners, and ad refresh sizing.

By FluxWriter Team

Cumulative Layout Shift on Mobile: The 5 Ad and Font Patterns That Wreck CLS

Cumulative Layout Shift (CLS) is one of those metrics that looks fine on desktop and quietly destroys mobile UX. On a phone, where there's no cursor to anchor the user's eye, a shifting layout sends tap targets flying—and Google's scoring reflects every jump. This article isolates five specific patterns involving ads, fonts, and lazy-loaded media that account for the vast majority of mobile CLS violations, along with concrete fixes for each.


Why Mobile CLS Is a Different Problem

The CLS formula weights both the impact fraction (how much of the viewport moves) and the distance fraction (how far it moves). On a 390 px-wide phone viewport, a 250 px ad unit that pushes a navigation bar 60 px downward moves 15% of the viewport content by 15% of the total viewport height—a CLS contribution of 0.0225 from a single element. Do that on desktop with a 1440 px wide screen and the same physical shift is geometrically smaller.

Add tap latency on top: if a user reaches for a link at 80ms after page load, and an ad slot renders at 200ms and bumps that link 80 px downward, the tap lands on a different element entirely. That's not a metric problem—it's a product problem.

Google's threshold for a "good" CLS score is ≤ 0.1, "needs improvement" is 0.1–0.25, and anything above 0.25 is poor. Chrome User Experience Report data shows that the bottom 25% of pages by CLS are almost entirely driven by mobile sessions where ad and font loading happens without reserved space.


Pattern 1: Ad Slots Without Explicit Dimensions

This is the single biggest CLS source in ad-supported publishing. When an ad tag fires, the container has no declared height, so the browser renders it at 0 px and then reflows once the creative arrives.

What it looks like: A <div id="ad-top"> with no CSS height, placed above the article body. After the header renders, the ad creative loads and pushes the entire article 250 px down.

The fix: Reserve space before the creative loads.

#ad-top {
  min-height: 250px; /* or 90px for a leaderboard */
  width: 100%;
}

For responsive units where you don't know the creative size in advance, use the ad network's slot.renderEnded callback (Google Publisher Tag) or the slotRenderEnded event to set a firm height only after confirming the creative size—but pre-allocate the most common size as a starting minimum.

The same rule applies to sticky ad slots that inject into the DOM after scroll: if you're inserting an element that wasn't in the initial render, it must appear outside the viewport or replace an existing element that already holds its space.


Pattern 2: Web Font Swaps That Shift Line Heights

FOUT (Flash of Unstyled Text) is a UX problem. FOIT (Flash of Invisible Text) avoids visible flash but can still cause CLS when the fallback and web font have different metrics. The shift happens silently—no visible flicker, but the layout reflows.

The specific culprit: font-display: swap combined with a fallback font that has meaningfully different line height, x-height, or letter spacing. A paragraph set in a fallback sans-serif at 18 px / 1.5 line-height that then swaps to a custom typeface with tighter metrics will reflow every text block on the page.

Font Metric Comparison (example)

Property System UI (fallback) Custom Web Font
Ascender 0.90 0.82
Descender −0.21 −0.19
Line gap 0 0.10
Apparent height ~1.11 units ~1.11 units

Even when total height appears identical, differences in ascender/descender ratios shift inline element baselines, which shifts surrounding block positions.

The fix: Use the CSS size-adjust, ascent-override, descent-override, and line-gap-override descriptors in your @font-face rule for the fallback font so it matches the web font's metrics before the swap occurs.

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

Tools like fontaine or the Wakamaifondue tool can generate these overrides automatically from a font file.


Pattern 3: Lazy-Loaded Images Without Aspect-Ratio Reservation

loading="lazy" is table stakes for mobile performance, but it causes CLS if the browser doesn't know the image dimensions before it loads.

Wrong:

<img src="hero.jpg" loading="lazy" alt="Hero image">

Right:

<img src="hero.jpg" loading="lazy" width="800" height="450" alt="Hero image">

When width and height are present, modern browsers automatically infer the aspect ratio and reserve the appropriate space. Without them, the image collapses to 0 px height until it enters the viewport and loads, causing a shift.

The same applies to <picture> elements and CSS background-image when the container height depends on the image. For background images, use the aspect-ratio property on the container:

.hero-bg {
  aspect-ratio: 16 / 9;
  background-image: url("hero.jpg");
  background-size: cover;
}

Important edge case on mobile: Hero images set with width: 100%; height: auto will reserve space only if intrinsic dimensions are declared. This is the most common oversight in responsive image setups.


Pattern 4: Dynamically Injected Banners and Cookie Consent Bars

Cookie consent banners, newsletter prompts, and floating bars are almost always injected via JavaScript after the initial render. If they appear at the top of the viewport on mobile, they push everything below them—classic CLS.

The correct approach is to include the banner in the initial HTML, hidden via CSS, and then reveal it in place rather than injecting it. If the element already occupies space in the DOM (even if visibility: hidden rather than display: none), it won't cause a layout shift when it becomes visible.

<!-- In HTML, always present, hidden until consent status is unknown -->
<div id="cookie-banner" class="cookie-banner" aria-hidden="true">
  ...
</div>
.cookie-banner { visibility: hidden; min-height: 60px; }
.cookie-banner.active { visibility: visible; }

If the banner must be injected (third-party script, no server-side control), place it at the bottom of the viewport or as an overlay that doesn't affect document flow (position: fixed with a CSS transform, not a margin-push). The same applies to chat widgets that expand into the document.


Pattern 5: Ad Refresh Without Dimension Guarantees

Many ad setups use refresh on a timer (30–60 seconds) to serve a new creative to the same slot. If the refreshed creative is a different size than the original—say, the first creative was 250×250 and the refresh serves a 300×250—the container resizes and shifts surrounding content.

This is especially common on mobile where ad demand changes throughout the session and different advertisers win different refresh auctions with different creative sizes.

The fix: Clamp the container to the maximum expected creative size from the start, and use overflow: hidden so smaller creatives don't collapse the container.

#ad-slot-mid {
  min-height: 250px;
  max-height: 250px;
  overflow: hidden;
}

For networks that support it (GAM 360, Amazon TAM), configure the slot to only accept creatives within a size range you've pre-allocated. This means you may occasionally serve a smaller creative with unused whitespace, but you avoid layout shifts on refresh—a clear tradeoff worth making.


Measuring These Patterns in Isolation

Google's CrUX data aggregates shifts across user sessions. To isolate which pattern is causing your CLS, use the Layout Instability API in a manual trace:

new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    if (!entry.hadRecentInput) {
      console.log('CLS entry:', entry.value, entry.sources);
    }
  }
}).observe({ type: 'layout-shift', buffered: true });

The entry.sources array will return the specific DOM nodes that shifted, their previousRect, and their currentRect. Cross-reference the node IDs against the five patterns above to prioritize fixes.

WebPageTest's filmstrip view is also useful: step through frames at 100ms intervals on a throttled mobile profile (Moto G4, 3G) and you'll see exactly when each shift occurs relative to ad and font loading.


FAQ

Does position: sticky on a nav bar cause CLS?

Not on its own. A sticky nav that's already in the document flow doesn't shift surrounding content—it moves out of flow only when it sticks. The CLS risk appears if the sticky element is added to the DOM after initial render (e.g., a sticky header injected by a scroll event listener), which does cause a shift. Include sticky elements in the initial HTML and let CSS handle the sticking behavior.

My CLS is 0 in Lighthouse but poor in CrUX. Why?

Lighthouse runs a synthetic single load in a controlled environment—no returning-user ad cookies, no third-party personalization scripts, no A/B test variants. CrUX captures real user sessions, which include ad refresh cycles, consent banners triggering on first visit, and fonts loading from a cold cache. The gap is almost always real-world ad behavior that lab tests don't replicate. Run WebPageTest with ad-blocking disabled and a fresh cookie jar for a closer approximation.

Can I use transform animations without causing CLS?

Yes. CSS transform and opacity changes don't trigger layout recalculation and are explicitly excluded from CLS measurement. If you need to animate an element's position, use transform: translateY() rather than changing top or margin-top. The PerformanceObserver API will not log these as layout shifts.


Practical Takeaway

The five patterns above—unsized ad slots, mismatched font metrics, dimensionless lazy images, injected banners, and ad refresh without size constraints—cover the overwhelming majority of mobile CLS problems in ad-supported sites. Fix them in that order: ad slot reservations have the highest leverage because they're systemic, while font metric overrides require more precision but affect every text-heavy page.

If you're producing content at scale and want every published page to start with clean markup that doesn't introduce CLS through template choices, FluxWriter generates structured article output with explicit image dimensions and semantic heading order—one less layer of layout-shift debt to debug after the fact.



← All posts