← Recipes

阅读中文版本

Reduce CLS by Measuring Shifts Before Fixing Them

Record layout-shift entries with sources, rank which elements move and when, then reserve exact space instead of guessing at CSS.

~30 minperformancefrontendweb-vitals

Task

Attribute CLS to specific elements and lifecycle moments, then eliminate each source with explicit dimensions.

When to use this

The dashboard reports CLS 0.21 with no clue about the culprit. The team guesses between fonts, images, and the delayed chart, and ships three blind fixes.

Prerequisites

  • Chrome or a Chromium browser with DevTools
  • A page with a measurable CLS problem
  • Access to the component code for dimension fixes

Steps

1. Capture layout-shift entries with their sources in the console.

// paste in DevTools console, then reload and interact
new PerformanceObserver((list) => {
  for (const e of list.getEntries()) {
    console.log(
      'shift', e.value.toFixed(4),
      'at', Math.round(e.startTime), 'ms',
      'sources:', e.sources.map((s) => s.node?.nodeName + '.' + (s.node?.className || '')).join(', ')
    );
  }
}).observe({ type: 'layout-shift', buffered: true });

Expected output: Lines print like shift 0.1140 at 820ms sources: IMG.hero, DIV.article-meta, naming both the mover and the moment.

2. Rank the shifts by contribution and cluster them by start time.

echo "paste the console lines into shifts.txt"; sort -k2 -rn shifts.txt 2>/dev/null | head -8 || echo "record with DevTools > Performance > Layout Shift regions"

Expected output: Sorted entries show the top contributors; start times cluster around image load, font swap, or data fetch, identifying the trigger class.

3. Reserve exact space for the late-arriving content.

<!-- before: image arrives, pushes everything down 312px -->
<img src="/hero.webp" alt="dashboard">

<!-- after: aspect-ratio box holds the space from first paint -->
<img src="/hero.webp" alt="dashboard"
     width="1200" height="628" style="aspect-ratio: 1200 / 628">

<!-- skeleton with the FINAL height, not a guess -->
<div class="chart-slot" style="height: 420px">
  <div class="skeleton" data-testid="chart-skeleton"></div>
</div>

Expected output: The hero image no longer displaces content when bytes arrive, and the chart skeleton occupies the exact final height, removing the two largest shift sources.

4. Stabilize the font swap that moves text after paint.

/* before: fallback metrics differ -> headings jump on swap */

/* after: match fallback metrics to the webfont */
@font-face {
  font-family: 'Inter';
  src: url('/inter-var.woff2') format('woff2-variations');
  font-display: swap;
  ascent-override: 96.37%; descent-override: 24.21%;
  line-gap-override: 0%;
  size-adjust: 100.06%;
}

/* plus a fallback stack with matched x-height */
font-family: 'Inter', 'Helvetica Neue', Arial, sans-serif;

Expected output: Re-running the observer shows the 60-200ms text jumps shrink to near zero after the swap metrics match.

5. Re-measure until the total is under threshold.

node -e "const s=[0.114,0.062,0.031,0.004];console.log('CLS',s.reduce((a,b)=>a+b).toFixed(3))" && echo "target <= 0.05; re-run the PerformanceObserver to confirm"

Expected output: The recomputed session CLS prints; iterating per source until the sum is at or under 0.05 turns a vague metric into a completed checklist.

Troubleshooting

Problem

PerformanceObserver logs nothing

Solution

The observer must be registered before the shifts happen; use buffered: true on a fresh reload, and check the page is not served in an iframe where the metric is reported to the parent frame.

Problem

Sources list is empty or null nodes

Solution

Elements were removed before the shift entry was read; capture a trace instead, and use the Layout Shift regions overlay in the Performance panel to see the moved rectangles.

Problem

Dynamic content genuinely needs to grow

Solution

Insert new content only below the fold, or animate it in with fixed height: growing downward below the viewport contributes nothing to CLS, while moving existing text does.

Limitations

  • DevTools CLS reflects one device and one session; validate with field data (Chrome UX Report or RUM) after shipping.
  • aspect-ratio and metric overrides rely on correct intrinsic dimensions; wrong numbers trade shifts for letterboxing.

CLS is a sum with named addends

A CLS score of 0.21 is a symptom, not a diagnosis. The PerformanceObserver turns it into an itemized receipt: which elements moved, by how much, and at which millisecond. Fixing the two largest line items is almost always enough; guessing at CSS usually is not.

shift 0.1140 at 820ms  sources: IMG.hero              <- no reserved space
shift 0.0620 at 190ms  sources: H1.title, P.lede      <- font swap metrics
shift 0.0310 at 2400ms sources: DIV.chart-slot        <- skeleton guessed short
shift 0.0040 at 3100ms sources: BUTTON.cta             <- ignore: negligible

after fixes:
shift 0.0031 at 190ms  sources: P.lede               <- total CLS: 0.006

Reserve the exact space, not an approximate one

The skeleton that guesses a height causes its own shift when the guess is wrong. Derive the reserved height from the real final layout: measured charts, aspect-ratio boxes from intrinsic dimensions, or a cached height from the previous session. An approximation is just a smaller, postponed shift.

Fix by source class, not by CSS reflex

The start-time clustering maps each shift to a trigger: image load wants dimensions, font swap wants metric-matched fallbacks, data fetch wants exact skeletons. Applying the image fix to a font problem is how teams ship three blind fixes and keep the same score.