The Announcement Bar That Cost 0.31 CLS

Every element on the page moved down 44 pixels, a third of a second after paint. Finding the culprit took one API and a screen recording.

Share
The Announcement Bar That Cost 0.31 CLS. Abstract bug hunt illustration in orange and dark grey on debugly.dev

A store failing Core Web Vitals on CLS alone. LCP was fine, INP was fine, and CLS sat at 0.31 against a 0.1 threshold. The merchant's description was that the page "jumps a bit" on load, which undersells it considerably.

Layout shift is the easiest of the three metrics to fix and the hardest to notice, because on a fast laptop with a warm cache it does not happen to you.

Seeing the shift instead of guessing

The console does this better than any tool:

let total = 0
new PerformanceObserver((list) => {
  for (const e of list.getEntries()) {
    if (e.hadRecentInput) continue
    total += e.value
    console.log('shift', e.value.toFixed(4), 'total', total.toFixed(4),
      e.sources.map(s => s.node))
  }
}).observe({ type: 'layout-shift', buffered: true })

That logs the actual DOM nodes that moved. On this store, one entry accounted for 0.28 of the 0.31, and the node was the entire main element, which means something above it changed height.

The something was the announcement bar.

Tested on Dawn 15, Chrome 133.

Hypothesis one: the bar loads its content from an app

Plausible, because app injected banners are a classic source of this. If a script fetches promotional text and inserts a bar at the top of the body, everything below moves.

Not this one. The bar was rendered in Liquid, present in the initial HTML, with text in it from the first paint. I confirmed by disabling JavaScript entirely and the bar still appeared instantly.

So the bar existed at paint. It just changed size afterwards.

Hypothesis two: the text is wrapping to two lines

Closer. I recorded the load at 5x slowdown and stepped through frame by frame, which is the technique that solved this and which I should have started with.

At frame 8 the bar was one line, 44 pixels tall. At frame 14 it was two lines, 88 pixels tall. The text had not changed. The font had.

The actual cause: a font swap that changed the metrics

The theme loaded a custom font with font-display: swap. Before the font arrives the browser renders in a fallback, and if the fallback's metrics differ from the real font, text reflows when the swap happens.

Here the fallback was narrower. The announcement text fitted on one line in the fallback and wrapped to two in the real font. A 44 pixel height change at the very top of the document pushes every single element below it down, which is why the shift value was so large: CLS is weighted by how much of the viewport moved, and this moved all of it.

This is the mechanism behind a whole family of shifts that look unrelated. Anything sized by its text content can do it: buttons, badges, product titles, price rows, navigation items.

What I changed

Fixed the height of the announcement bar. The bar is a known piece of content in a known place, so reserving its space is trivial and completely removes the shift regardless of what the font does:

.announcement-bar {
  min-height: 44px;
  display: flex;
  align-items: center;
}

@media (max-width: 749px) {
  .announcement-bar { min-height: 40px; }
}

That alone took CLS from 0.31 to 0.04. Everything below is refinement.

Matched the fallback metrics. The general fix, and the one that helps every other text element on the page, is to declare a fallback whose metrics approximate the real font so the swap does not reflow anything:

@font-face {
  font-family: "Theme Sans Fallback";
  src: local("Arial");
  size-adjust: 107.4%;
  ascent-override: 92%;
  descent-override: 24%;
  line-gap-override: 0%;
}

body {
  font-family: "Theme Sans", "Theme Sans Fallback", system-ui, sans-serif;
}

The percentages are specific to the font pair and you calculate them rather than guess. The Fontaine library and Google's own font metrics overrides tooling both generate them. Getting size-adjust right is the part that matters, because it controls the width and therefore the wrapping.

Preloaded the font that is used above the fold. One font file, the weight that actually appears in the header and hero:

<link rel="preload" as="font" type="font/woff2" crossorigin
      href="{{ 'theme-sans-600.woff2' | asset_url }}">

crossorigin is mandatory even for same origin font preloads. Omit it and the browser downloads the file twice, which I have watched happen on more than one theme.

The other three shifts on that page

Once the big one was gone, the remaining 0.04 broke down into three familiar patterns worth naming:

Product images without dimensions. Every img needs width and height attributes so the browser reserves the aspect ratio before the file arrives. Shopify gives you image.width and image.height on the object, so there is no excuse.

A discount badge injected by an app. It appeared after its script ran and pushed the price down. The fix is not in your control if the app owns the markup, but you can reserve space with a min-height on the container that holds it.

Lazy loaded sections with no placeholder. A recommendations section that renders after a fetch. Reserve the height with a skeleton, or load it below the fold where the shift is not counted against you if the user has not scrolled there.

What I would do differently

Record the load before forming a theory. Chrome DevTools performance panel, 5x CPU throttle, screenshots enabled. Stepping through frames showed me exactly what moved and when in about ninety seconds. My first two hypotheses were both wrong and both would have been avoided by looking at the recording first, which is the same look at the evidence before theorising discipline that applies to every bug.

Treat fonts as a layout dependency, not a styling detail. A font swap is a layout event. Any element sized by its text is a candidate for shifting, and the announcement bar is the worst possible place for it because it moves the whole document.

Test with a cold cache and a slow connection. With the font cached, none of this reproduces. The merchant's customers were mostly first time visitors on mobile, which is precisely the population that experienced it and precisely the one I was not simulating.

Reserve space for anything an app might inject. You cannot control third party markup, but you can control the box it lands in. I go through the measurement side of app overhead in how much your apps are actually costing you.


Published on debugly.dev by Rohit Bhadani | Founder of Krova Cloud