Scenario Debugging

Layout Shift and Janky Interactions

Layout shift from images, ads and late fonts, and slow input from heavy event handlers. It ends with a short checklist.

Lesson 0812 min read#cls#inp#css

On this page

  1. Words used in this lesson
  2. Part 1: layout shift (CLS)
  3. Part 2: janky interactions (INP and frame drops)
  4. A short checklist
  5. Common mistakes
  6. Interview questions
  7. Key takeaways
  8. What's next

In one line: layout shift is when content moves after the user has started reading it or aiming at it. Jank is when the page does not respond or animate smoothly. You fix shift by reserving space. You fix jank by keeping the main thread free and reacting to input at once. (The main thread is the one thread that runs your JavaScript and also draws the page.)

Both problems are about trust. A button that jumps as you tap it sends you to the wrong page. An input that shows each letter late makes the app feel broken. They match two Core Web Vitals (Google's measures of page quality): CLS and INP. See Browser Contract, Lesson 09.

The two measures in plain words

  • CLS (Cumulative Layout Shift) is a score that adds up how much visible content moves without the user asking for it. Lower is better. Example: a text line jumps down because an image above it finally loaded.
  • INP (Interaction to Next Paint) is a time that measures how long the page takes to show a visible response after a click, tap or key press. It reports one of the slowest interactions of the visit. A good INP is 200 ms or less at the 75th percentile.
  • Difference in one line: CLS measures things moving by surprise; INP measures the page answering too slowly.

Words used in this lesson

  • Layout (also called reflow) is the step where the browser works out the size and position of every element. Paint is the step where it draws the pixels. Changing height forces layout again. Changing transform does not.
  • Compositor is the part of the browser that moves already-drawn layers around, often on the graphics chip. transform and opacity can run there, so they are cheap.
  • Long task is any piece of main-thread work that runs for more than 50 ms. The page cannot react to the user during it.
  • startTransition is a React function that marks a state update as not urgent, so React keeps the page responsive and does that update later. useDeferredValue is a React hook that gives you a delayed copy of a value, so the heavy part of the screen can lag behind the typing.
  • scheduler.yield() is a browser function that pauses your code, lets the browser handle clicks and painting, and then continues. setTimeout(fn, 0) does a similar thing the old way: it runs fn in a later task.
  • requestIdleCallback is a browser function that runs your code when the browser has nothing urgent to do. requestAnimationFrame is a browser function that runs your code just before the next frame is drawn.
  • Web Worker is a separate thread that runs JavaScript in the background. It cannot touch the page, but it does not block it either.
  • Debounce means waiting until the user pauses (for example 300 ms) before doing the work. Throttle means doing the work at most once per time period. Difference in one line: debounce waits for quiet; throttle limits the rate.
  • content-visibility: auto is a CSS property that tells the browser to skip drawing and layout for content that is off screen.
  • Skeleton is a grey placeholder box shaped like the content that will appear. Above the fold is the part of the page you see without scrolling.
  • Percentile (p75) is the value below which a given share of results fall. The 75th percentile is the value that 75 out of 100 visits are at or below.

Part 1: layout shift (CLS)

What counts

A layout shift happens when a visible element changes its start position between two frames, and the user did not cause it. Each shift gets a score:

TEXT
shift score = impact fraction × distance fraction

The impact fraction is how much of the viewport (the visible part of the page) the moving content touched, before and after the move. The distance fraction is how far it moved, compared with the viewport. CLS reports the worst group of shifts during the life of the page. A group (a "session window") is a run of shifts with gaps of less than 1 second between them, and at most 5 seconds long. A good score is 0.1 or less at the 75th percentile (three out of four visits are at or below this score). A score above 0.25 is poor. Shifts that start within about half a second (500 ms) after a click, tap or key press are not counted, for example opening a menu or clicking "show more". The user expects those. Scrolling and hovering do not count as input for this rule.

Common causes and fixes

CauseWhat happensFix
Images, video and iframes without dimensionsThe browser does not know the height until the file arrives. Then it pushes content down.Set width and height attributes (the browser works out the aspect ratio, the width-to-height proportion, from them), or use CSS aspect-ratio. next/image needs dimensions (a statically imported image gets them for you), or fill with a parent that has a size.
Late-loading ads, embeds and widgetsAn empty slot suddenly becomes 250 px tall.Reserve the slot size with min-height (use the most common ad size). Collapse the slot only below the fold or after the user has scrolled past.
Banners and notices added above contentA cookie banner, promo bar or "related" strip pushes everything down.Reserve space in advance. Or add it below the visible content. Or float it over the page with position: fixed/sticky and transform, so it does not push the layout.
Web font swapThe fallback text moves when the real font loads, because the letters have different sizes.Use font-display: swap (show a fallback font at once, then swap) plus a fallback with matched metrics (size-adjust, ascent-override; next/font makes these for you). Or use font-display: optional (use the web font only if it loads very fast). Preload the key font (ask the browser to fetch it early).
Content added by client-side renderingThe skeleton (grey placeholder) and the final content have different heights.Make skeletons match the final size. Render the top of the page on the server. Use fixed-height boxes for lists with rows of known size.
Animations that use layout propertiesAnimating top, height or margin makes the browser redo layout (work out every element's size and position) on every frame.Animate transform and opacity.
Lazy-loaded images without reserved spaceThe page jumps as images come into view.Same fix as for images: set dimensions or aspect-ratio.
Dynamic content after hydrationA client component swaps some text (language, a logged-in name) and the lines wrap differently. Hydration is when React takes the HTML made on the server and attaches JavaScript to it, so it becomes interactive.Render the same thing on the server and the client. Reserve the width. Avoid per-user changes that move the layout after the first paint (the first time the browser draws content).
Responsive images with unknown ratioThe box changes size on load.Use aspect-ratio on the box.
CSS
/* Reserve space before anything loads */.hero img    { width: 100%; height: auto; aspect-ratio: 16 / 9; }.ad-slot     { min-height: 250px; }.avatar      { width: 40px; height: 40px; }.skeleton-row{ height: 56px; }               /* equals the real row height *//* Scroll anchoring keeps the visible content steady when content above it changes (`none` turns it off) */.chat-log { overflow-anchor: auto; }
JavaScript
// dynamic() loads a component only when needed (lazy loading). The loading box keeps the space reserved.const MapView = dynamic(() => import("./MapView"), { ssr: false, loading: () => <div style={{ height: 400 }} /> });

Debugging CLS

  1. Chrome DevTools, Performance panel (DevTools is the set of debugging tools built into Chrome): record a page load. The Layout shifts track (in older Chrome versions it is part of the Experience track) marks each shift. Click one to see which nodes moved and the score. The Insights sidebar also has a "Layout shift culprits" entry.
  2. Rendering tab, "Layout Shift Regions": it flashes blue where shifts happen, live.
  3. Lighthouse points to the elements to blame ("Avoid large layout shifts"; newer versions call it "Layout shift culprits"). It also warns about "Image elements do not have explicit width and height".
  4. In code, with a PerformanceObserver (a browser API that calls your function when a performance event happens, such as a layout shift):
JavaScript
new PerformanceObserver((list) => {  for (const entry of list.getEntries() as any[]) {    if (entry.hadRecentInput) continue;               // user-initiated shifts don't count    console.log("shift", entry.value.toFixed(4), entry.sources?.map((s: any) => s.node));  }}).observe({ type: "layout-shift", buffered: true });
  1. Real-user data: web-vitals is Google's small JavaScript library that measures Core Web Vitals in real visits. Its attribution build reports the largest shift source for each page view. So you can see which element shifts for real users.

Scenario: the page jumps when a banner loads. Fix it.

  • Diagnose with Layout Shift Regions or the Performance track. A script adds the banner <div> at the top after the first paint, and it pushes the page down.
  • Fix options (the first is best):
    1. Render the banner on the server, so it is in the first HTML.
    2. Reserve its height with a fixed-size box (CSS min-height) before the script runs.
    3. Make it an overlay (position: fixed pins it to the screen, for example at the bottom), so nothing moves.
    4. Animate it with transform, not layout properties.
  • Prevent: add a Lighthouse CI check on CLS (Lighthouse is Chrome's automatic page audit tool; Lighthouse CI runs it on every code change). Set RUM alerts (RUM means real user monitoring: data from real visits) for CLS getting worse. Add a code-review rule: "anything that appears after load must have reserved space."

Part 2: janky interactions (INP and frame drops)

What it feels like

  • You type in an input: the letters appear late or in bursts.
  • You click a button: nothing happens for a moment, then everything happens at once.
  • Scrolling stutters. Animations skip frames.

INP splits one interaction into three parts:

  • Input delay: the time before your code can start, because the main thread was busy when the user acted.
  • Processing time: the time your event handlers (functions that run when an event such as a click happens) take to run.
  • Presentation delay: the time the browser needs to calculate and draw the result on screen.

All three usually have the same root cause: the main thread is busy. See Browser Contract, Lesson 08.

Causes and fixes

CauseFix
A heavy event handler that does too much at once (validation, filtering 10k items, analytics, logging)Do the minimum to update the UI. Then defer the rest (do it later) with startTransition, setTimeout(0)/scheduler.yield(), requestIdleCallback, or a Web Worker.
A big re-render caused by the input (each key press re-renders the page)Keep state close to where it is used. Memoise (remember a result so it is not worked out again). Use useDeferredValue (Lesson 03).
Controlled inputs in giant forms (React holds every value in state)Use uncontrolled inputs (the browser keeps the value), or a form library that keeps field re-renders apart (React Hook Form). Validate on blur (when the field loses focus) or after a short pause in typing (debouncing).
Long tasks from third-party scriptsLazy-load them, delay them until after interaction, remove them, or run them in a worker.
Layout thrashing in handlers or scroll listeners (mixing reads and writes of layout, so the browser recalculates again and again)Do all reads first, then all writes. Use requestAnimationFrame. Use observers instead of polling sizes (ResizeObserver and IntersectionObserver are browser APIs that call your function when an element changes size or becomes visible).
Scroll and touch listeners that are not passiveUse { passive: true }, which promises the browser that the listener will not cancel the scroll, so scrolling does not wait for it. Avoid synchronous work in them.
A huge DOM that makes every style and layout pass expensiveVirtualise, which means rendering only the rows that are on screen (Lesson 07). Use content-visibility.
Animations on layout propertiesUse transform and opacity. Use will-change (a hint that a property will change) only a little.
Expensive CSS (deep selectors, large box-shadow or filter on many elements)Simplify it. Reduce the painted area. Let the compositor (the part of the browser that moves finished layers) do the work.
Synchronous localStorage (it blocks the thread while it reads or writes), big JSON parsing or structuredClone (a deep copy) in handlersMove them off the main thread, or do them later.

Yield so the browser can paint first. This is the "acknowledge, then work" pattern:

JavaScript
button.addEventListener("click", async () => {  button.classList.add("loading");                       // 1. immediate visual feedback  await (globalThis.scheduler?.yield?.() ?? new Promise((r) => setTimeout(r, 0))); // 2. let the browser paint  const result = expensiveWork();                         // 3. then the heavy part  render(result);});

In React, startTransition gives you the same shape. (scheduler.yield() is not in every browser, so the code falls back to setTimeout.) You update the urgent state now. The expensive state updates in the background.

Debugging janky interactions

  1. Performance panel: record the interaction with CPU throttling (slowing the CPU down on purpose, so your fast laptop behaves like a phone). The Interactions track shows how long each interaction took and splits it into input delay, processing and presentation. Click a long one and look at the flame chart (each bar is a function call; a wider bar means more time) for the function that takes the most time.
  2. Look for long tasks (tasks over 50 ms; DevTools marks them with a red corner) near the interaction, and for forced reflow warnings (JavaScript asked for a size while layout was out of date, so the browser had to redo layout at once).
  3. Chrome's Long Animation Frames (LoAF) API (it reports frames that took over 50 ms to draw) and the DevTools Insights link long frames to scripts and callbacks. This helps you blame a specific handler or third-party script.
  4. Use the React Profiler (a React DevTools tool that records which components rendered and how long each took) to see the re-render cost during the interaction.
  5. Real-user INP: the web-vitals attribution build shows the slowest interaction target and which phase caused the delay.
JavaScript
import { onINP } from "web-vitals/attribution";onINP(({ value, attribution }) => {  navigator.sendBeacon("/vitals", JSON.stringify({    inp: value,    target: attribution.interactionTarget,     // which element    type: attribution.interactionType,         // click, keyboard…    inputDelay: attribution.inputDelay,    processing: attribution.processingDuration,    presentation: attribution.presentationDelay,  }));});

A short checklist

Before launch

  • Every image, video, iframe and ad has reserved dimensions.
  • Fonts use font-display with a matched fallback (or next/font).
  • Skeletons match the final heights. Anything dynamic has reserved space.
  • Animations use transform and opacity.
  • Event handlers are light. Heavy work is deferred or runs off the main thread.
  • Scroll and touch listeners are passive. There are no layout reads in loops.
  • Large lists are virtualised or paginated (split into pages).
  • Third-party scripts are lazy-loaded and measured.

In production

  • RUM tracks CLS and INP with attribution (which element, which phase), split by route and device.
  • Alerts fire when numbers get worse. Lighthouse CI guards the main pages.
  • Review every new UI pattern with two questions: "What moves when this loads?" and "What runs when this is clicked?"

Common mistakes

  1. Images without dimensions (still the number one cause of CLS).
  2. Adding content above existing content after load.
  3. Skeletons with different dimensions from the final content.
  4. Font swaps with no size-matched fallback.
  5. Animating layout properties.
  6. Doing all the work inside the click handler before the UI updates.
  7. Controlled inputs that re-render a huge form on each key press.
  8. Not testing on slowed-down CPUs and real devices.

Interview questions

What causes CLS?

CLS comes from content that changes position after it has been drawn. Common causes:

  • Images, video or iframes without reserved dimensions.
  • Ads and embeds that load late.
  • Banners or content added above existing content.
  • Web fonts that swap in with different metrics.
  • Skeletons that do not match the final size.
  • Animations that change layout properties.

I fix them by reserving space, matching the fallback fonts, and animating transform.

Scenario: the page jumps when a banner loads. Fix it.

I find the shift with the Layout Shift Regions overlay or the Performance panel. Then I do one of three things. I render the banner in the first HTML. Or I reserve its height before it loads. Or I make it an overlay so it does not push content. I animate with transform. To stop it coming back, I add CLS monitoring and a CI check.

How do you reserve space for dynamic content?

For media, I use width and height or aspect-ratio. For slots and skeletons, I use a fixed or minimum height that matches the real content. For sections that load later, I use contain-intrinsic-size, a CSS property that gives the browser a placeholder size for content it has not drawn yet. If I do not know the height, I reserve the typical size. Or I load the content in an overlay or below the fold.

Why does input lag happen and how do you fix it?

There are three possible reasons. The main thread is busy when the event arrives (input delay). The handler does too much (processing time). Or drawing the result is expensive (presentation delay). To fix it:

  • Do the minimum work in the handler.
  • Defer the rest with transitions, yielding or workers.
  • Make re-renders smaller.
  • Virtualise big lists.
  • Move or remove heavy third-party scripts.
How do you debug a slow interaction?

I record it in the Performance panel with CPU throttling. I look at the Interactions track to see which phase takes the longest. I open the flame chart for the long task. I check for forced reflows and for React re-render cost. To find the worst interaction in real use, I use the web-vitals attribution build.

How do you prevent font-related layout shift?

I use font-display: swap or optional. I give the page a fallback font adjusted to match the web font's metrics (size-adjust, ascent-override). I preload the critical font and self-host it with subsetting (keeping only the characters I need, so the file is smaller). next/font does most of this for me.

Which interactions are excluded from CLS and why?

Layout shifts that start within about 500 ms after a user input (a click, a tap or a key press) are not counted. The user expects the page to change after they act. What hurts the experience is movement that nobody asked for, with no recent input.

Key takeaways

  • CLS means reserve space. Give media dimensions. Use fixed slots for late content. Match skeletons and fonts. Add banners below the content or as an overlay. Animate transform.
  • Jank and INP mean free the main thread. Keep handlers light. Defer the rest. Make re-renders narrow. Use passive listeners. Batch layout reads. Virtualise lists. Lazy-load third parties.
  • Use Layout Shift Regions and the Interactions track. Collect attribution data in production.
  • Guard with Lighthouse CI and RUM alerts. Review every new late-loading or interactive UI with these two questions.

What's next

Lesson 09: Quick Scenario Round. Fifteen common "what would you do?" questions. Each answer has three steps: diagnose, fix, prevent.

Published by Jainik · Last updated · How these lessons are made

Keep learning

Related lessons

Related series