How to Find the Source of Every Layout Shift in Chrome DevTools
This guide is the lab companion to Reducing Cumulative Layout Shift (CLS), part of Core Web Vitals & Measurement. RUM tells you CLS is 0.17 at p75 on a template; it rarely tells you which of the forty elements that moved was the cause. DevTools can, if you know where to look and how to distinguish the element that moved from the element that made it move.
That distinction is the crux. A layout shift entry lists the nodes whose position changed — the victims. The cause is usually something that changed size or was inserted above them: an image that loaded without dimensions, a font that swapped, an ad that resized. Effective debugging means walking from the victims back to the cause, at the exact moment the shift occurred.
Rapid Diagnosis
- Match the field conditions. Use the viewport and throttling from your RUM's failing segment. Many shifts only occur at specific widths or when a resource is slow.
- Enable the Layout Shift Regions overlay (Rendering drawer → "Layout Shift Regions") and reload. Moved areas flash blue; it is the fastest way to see where shifts happen.
- Record a Performance trace with "Screenshots" on. The Layout Shifts track shows each shift as a diamond, grouped into clusters (session windows) with their scores.
- Click the largest cluster. The summary lists each shift's score and the moved nodes; hovering a node highlights it on the page.
- Note the timestamp. Look at the Network and Main tracks at that exact moment: what finished loading or executing just before?
Root Cause Analysis: The Five Usual Culprits
1. Media without reserved dimensions. An <img>, <video> or <iframe> with no width/height attributes or CSS aspect-ratio renders at zero height, then expands when its dimensions are known. In the trace, the shift coincides with the resource's response arriving.
2. Late-inserted content. Banners, ads, embeds, "related items" rails or client-rendered components inserted above existing content. The shift coincides with a script task or a fetch completing.
3. Web font swaps. Text re-renders in the web font with different metrics, changing line heights and wrapping. The shift coincides with a font request completing, and the moved nodes are text blocks and everything below them.
4. Layout property animations. Animations or transitions on top, height, margin or width shift elements every frame. The trace shows a series of small shifts in quick succession.
5. Hydration or client re-render. A framework replaces server markup with differently sized client markup. The shift coincides with the hydration long task.
Step-by-Step Workflow
1. Log shift sources with their rectangles
The Performance panel shows moved nodes, but the console gives you precise before/after rectangles you can reason about.
new PerformanceObserver((list) => {
for (const e of list.getEntries()) {
if (e.hadRecentInput) continue;
console.groupCollapsed(`shift ${e.value.toFixed(4)} at ${Math.round(e.startTime)}ms`);
for (const s of e.sources) {
console.log(s.node, 'from', s.previousRect.toJSON(), 'to', s.currentRect.toJSON());
}
console.groupEnd();
}
}).observe({ type: 'layout-shift', buffered: true });
// trade-off: sources lists at most five nodes per shift, prioritised by
// impact. A shift that moves fifty nodes shows five — usually enough, but the
// true cause may not be among them; walk up the DOM from the first one.
Expected outcome: a list of shifts with nodes and exact pixel deltas, which tells you the size of whatever was inserted or resized.
2. Find what changed above the first moved node
The delta in previousRect.y → currentRect.y is the height of the change. Walk the DOM upwards and to the preceding siblings of the first moved node looking for an element of that height that appeared or grew. The Elements panel's DOM breakpoints ("Break on → subtree modifications") on the parent pause execution at the insertion, showing the call stack responsible.
// Pause when anything is inserted into the main content area.
const target = document.querySelector('main');
new MutationObserver((records) => {
for (const r of records) for (const n of r.addedNodes) {
if (n.nodeType === 1 && n.getBoundingClientRect().height > 0) { debugger; }
}
}).observe(target, { childList: true, subtree: true });
// trade-off: this pauses on every visible insertion, including legitimate
// below-the-fold ones. Narrow the selector once you know roughly where the shift is.
Expected outcome: the exact script and line that inserted the content.
3. Correlate with network and main-thread activity
In the trace, zoom to the shift and read the tracks above it. A font or image request ending at that instant, or a task from a third-party script, is almost always the trigger. Hover the request to see its initiator.
4. Confirm with a targeted change
Apply a minimal fix — add dimensions, reserve space, set font-display: optional temporarily — and re-record. If the shift disappears, you have the cause. If a smaller shift remains, repeat; pages often have two or three independent sources.
Verification
After fixing, run the page with the Layout Shift Regions overlay on at each relevant width and confirm no blue flashes outside of user-initiated changes. Record a final trace and check that the Layout Shifts track has no clusters above 0.01. Then confirm in RUM with the web-vitals attribution build: attribution.largestShiftTarget for the template should change from the old culprit to nothing in particular, and p75 CLS should drop below 0.1 over the following days.
Shifts the Lab Tends to Miss
Some shifts depend on conditions a single lab run does not reproduce. Know them so you can force them deliberately.
Slow third-party responses. An ad or embed that loads in 300ms in your lab may take 3s in the field, landing after the content below it has rendered. Use request blocking with a delay (or a proxy) to slow specific third-party origins.
Cached vs uncached fonts. On repeat visits fonts are cached and render immediately without a swap. Test with the cache disabled and enabled; the first visit is usually the problem.
User-specific content. Logged-in banners, personalised recommendations and A/B test variants insert different content for different users. Test the variants your RUM shows are most common among high-CLS sessions.
Scroll-dependent shifts. Lazy-loaded images without dimensions only shift when scrolled into view. Scripted traces that do not scroll miss them entirely; add a slow scripted scroll to the recording.
Viewport resizes on mobile. The mobile browser's address bar collapsing changes the viewport height, which can resize vh-based elements. Use svh/lvh/dvh units deliberately and test on a real device.
FAQ
Why does the moved element not look like it moved?
Shift sources report elements whose layout position changed between frames, even by a few pixels, and even if they are below the fold or partly off-screen. A footer that moved 4px counts if it was in the viewport. The score weights by how much of the viewport moved and how far, so small movements of large elements still add up.
Does a shift inside a scroll container count?
Yes, if the shifted elements are visible within the viewport. Shifts are computed relative to the viewport, so content moving inside a visible scrollable panel contributes just like content in the main document.
Can I see layout shifts in Lighthouse instead of a trace?
Lighthouse's "Avoid large layout shifts" audit lists the elements that contributed most to CLS during its load, with scores, which is a quick first pass. It only covers the load it observed — one viewport, one network profile, no scrolling and no interaction — so it misses most of the shifts described in the section on what the lab tends to miss. Use it to triage, then reproduce in the Performance panel with the conditions your field data points at.
Related
- Debugging CLS caused by dynamic ad injection — applying this workflow to ad slots.
- Fixing CLS from web font swap — the fix for the font-swap pattern.
- Fixing CLS from skeleton screens that resize — when the cause is a placeholder.