Skip to content

    Frontend System Design

    Cumulative Layout Shift and Layout Stability

    Learn what CLS counts and what it ignores, how to find the element that moved, and how to reserve space for images, embeds, fonts and live content.

    By Published 15 min read

    • frontend-system-design
    • core-web-vitals
    • cls
    • layout-stability
    Share

    Standard lessonintermediateFrontend System Design

    Before this: Loading Performance and Core Web Vitals

    What you'll learn: By the end of this lesson you will be able to say exactly which movements the browser counts as layout shifts and which it ignores, work out a shift score and a CLS value by hand, name the usual causes (images, embeds, injected content, fonts, animation, live updates) and the fix for each, find the element that moved in lab and field data, and design a page so late content has a place to land.

    Think of a railway departures board. You find your train, start walking to the platform, and the board reshuffles: your train is two rows lower and you board the wrong one. The information was correct both times; what hurt was that it moved after you had started to act on it.

    The Cumulative Layout Shift (CLS) metric counts that experience on a web page: visible content that moves while the page loads or updates, when the user did not ask for it. Loading Performance and Core Web Vitals introduced CLS next to LCP and INP. This lesson goes deeper on CLS alone: what the browser counts and ignores, why each common cause shifts, and how to build pages that stay still.

    The running example is a product page: a gallery, a title and price, a promotional banner, a late-loading reviews widget, a web font, and a "Load more reviews" button.

    Facts below were read on 2026-11-08 from the web.dev CLS page, the web.dev guide to optimizing CLS (last updated 7 February 2025), the WICG Layout Instability draft (a Community Group draft dated 6 November 2026, not a W3C standard) and MDN. At the time of writing web.dev rates CLS "good" at 0.1 or less and "poor" above 0.25, at the 75th percentile of page loads, segmented across mobile and desktop.

    The core idea

    A layout shift is a visible element that changes its start position between two rendered frames. CLS adds up the shifts the user did not cause. Two consequences organise the lesson.

    1. The browser can only keep content still if it knows the size early. Most shifts are a box that had no size when it was first laid out and got one later.
    2. Not every movement counts, and the fix depends on which kind you have. A box that grows without pushing anything is not a layout shift. A box that is animated with transform is not either. A box that pushes the reader's content down is.

    So the habit is: reserve space before content arrives, put late content where it moves nothing, and animate without changing layout.

    What the browser counts, and what it ignores

    Figure 1. Is this movement a layout shift that counts?

    flowchart TD accTitle: Which movements count towards CLS accDescr: Between two frames, a visible element whose start position changes is a candidate. Movement caused only by a transform, by scrolling, or of invisible elements is ignored. A shift within 500 milliseconds of a discrete input is flagged and excluded. Everything else counts, with a score of impact fraction times distance fraction. A["Visible element in two consecutive frames"] --> B{"Did its start position change?"} B -->|"no (new element or size change
    that moves nothing else)"| X["Not a layout shift"] B -->|"yes"| C{"Only a transform,
    scrolling, or invisible?"} C -->|"yes"| X C -->|"no"| D{"Within 500 ms of a
    discrete input?"} D -->|"yes"| E["hadRecentInput: reported
    but excluded from CLS"] D -->|"no"| F["Counts: impact fraction
    times distance fraction"]

    How to read this: CLS measures start positions, not sizes. The same late widget scores zero if it is added below everything and a large number if it pushes the content the reader was looking at.

    What the sources say about each step:

    • Start position. Web.dev: layout shifts "only occur when existing elements change their start position". A new element, or an element that changes size, is not a shift unless it makes other visible elements move.
    • Transforms and scrolling. Web.dev recommends transform animations because they do not trigger layout shifts. The draft is precise: an element counts as shifted only if both its transformed and its transform-indifferent start points move significantly, and scroll-driven movement is excluded.
    • Invisible elements. The draft requires the node to be visible with non-zero opacity in both frames.
    • Recent input. An entry gets hadRecentInput when the most recent excluding input was under 500 ms ago. The draft lists mousedown, keydown, pointerdown and change among excluding inputs; scrolls and mousemove do not exclude. Web.dev adds the principle: a shift after interaction is fine "as long as the shift occurs close enough to the interaction that the relationship is clear to the user".
    • Score. score = impact fraction x distance fraction. The impact fraction is the combined visible area of all unstable elements in the previous and current frame, as a share of the viewport. The distance fraction is the greatest distance any unstable element moved, divided by the larger viewport dimension.
    • Frames and restores. The API does not report shifts inside iframes, but the metric counts them. A page restored from the back/forward cache should have its CLS reset to zero.

    Illustration 1: the minimal case, scored by hand

    A phone viewport of 400 by 800 CSS pixels (illustrative). The article body starts 100 px from the top. The page has an <img> above it with no width or height, so it takes no height until it loads. Then the image arrives and is 300 px tall.

    <img src="/img/boot-1200.jpg" alt="Brown leather boot, side view">
    <article>...</article>

    The body moves from top 100 to top 400. The impact region is the body's visible area in both frames, which is 100 to 800, so 700 of 800 px of height: 0.875 of the viewport. The distance is 300 px over the larger dimension, 800 px: 0.375. Score: 0.875 x 0.375 = 0.328125, already above the "poor" line of 0.25 for CLS on its own. (Hand arithmetic with web.dev's formula, checked in Node; a real browser also applies a significance threshold and measures actual boxes.)

    With width="1200" height="900" on the image, web.dev says modern browsers derive a default aspect-ratio from those attributes, so once CSS gives the image a width (width: 100%; height: auto;) the browser knows its height before the file arrives and the body never moves. If the attributes disagree with the real file, the auto keyword lets the real ratio win after download: a smaller shift, not zero.

    Figure 2. An image without dimensions pushes the article down

    sequenceDiagram accTitle: Late image without reserved space accDescr: The browser lays out the page with the image at zero height and paints the article. The image bytes arrive, the browser learns the height, and the article moves down in the next frame. A layout shift is recorded. participant B as Browser participant N as Network B->>B: layout: image height unknown (0), article at top 100 B->>B: paint frame 1: reader starts reading the article N-->>B: image bytes arrive B->>B: layout: image is 300 px tall, article now at top 400 B->>B: paint frame 2: article moved 300 px (layout shift)

    How to read this: nothing is wrong with the download speed. The shift comes from laying out before the size was known. A faster image only shortens the window in which the reader can be misled.

    CLS is the worst burst, not the sum

    Shifts are grouped into session windows: a run of shifts with less than 1 second between each, capped at 5 seconds in total. Web.dev defines CLS as the score of the window with the largest cumulative score. Shifts flagged hadRecentInput are left out. The draft specification does not define windows: the API reports single shifts, and the draft treats cumulative scores in a non-normative section.

    A small function makes the rule concrete. This ran in Node 26.12.1 on 2026-11-08 with invented shift values:

    // Largest session window: a shift joins the current window if it comes less than
    // 1000 ms after the previous shift and the window is under 5000 ms old.
    function cls(shifts) {
      const used = shifts.filter((s) => !s.hadRecentInput);
      let best = 0, sum = 0, start = 0, last = 0, open = false;
      for (const s of used) {
        if (open && s.t - last < 1000 && s.t - start < 5000) {
          sum += s.value;
        } else {
          sum = s.value; start = s.t; open = true;
        }
        last = s.t;
        best = Math.max(best, sum);
      }
      return best;
    }
    
    const shifts = [
      { t: 900, value: 0.05 },
      { t: 1400, value: 0.04 },
      { t: 4000, value: 0.06 },
      { t: 4500, value: 0.07 },
      { t: 9000, value: 0.1, hadRecentInput: true },
    ];

    Output: the counted shifts add up to 0.22, but cls(shifts) is 0.13, the second window (0.06 + 0.07). The first window is 0.09, and the shift at 9,000 ms is excluded as input-driven. This is a teaching sketch, not a replacement for the web-vitals library.

    Figure 3. Where a shift lands in the windows

    flowchart LR accTitle: Session windows for the example shifts accDescr: Two shifts at 0.9 and 1.4 seconds form window one with 0.09. A gap of more than one second starts window two with shifts at 4.0 and 4.5 seconds totalling 0.13. The shift at 9 seconds had recent input and is excluded. CLS is the larger window, 0.13. S1["0.9 s: 0.05"] --> S2["1.4 s: 0.04"] S2 --> W1["Window 1: 0.09"] S3["4.0 s: 0.06"] --> S4["4.5 s: 0.07"] S4 --> W2["Window 2: 0.13"] S5["9.0 s: 0.10
    hadRecentInput"] --> X["Excluded"] W1 --> R["CLS = largest window = 0.13"] W2 --> R

    How to read this: a long, calm page can have many small windows and a low CLS. One burst is what raises it.

    Why each common cause shifts, and the fix

    Every row below is the same story: something is laid out before its size is known, or something is inserted above content the reader is using.

    Cause Why it shifts Fix (web.dev's guide unless noted)
    Images and video without dimensions Laid out at 0 by 0 until bytes arrive width and height attributes, or CSS aspect-ratio; MDN says explicit dimensions are especially important for lazy-loaded images, and notes that an unloaded image has a width and height of 0
    Ads, embeds, iframes, late widgets Inserted after first layout Reserve the slot in the initial layout with min-height or aspect-ratio
    Content injected without a user action New content pushes existing content Placeholders or skeletons; put late content below the viewport; let the user trigger it ("Load more")
    Web fonts Fallback and web font have different metrics, so text reflows at the swap font-display: optional, or a fallback tuned with size-adjust; preload the font so it can arrive before first paint
    Animations Animating top, left, width or height moves layout Animate transform
    Pages that are not bfcache-eligible A back navigation reloads the page and repeats its load-time shifts Make pages eligible for the back/forward cache, which web.dev calls highly effective

    Figure 4. Choosing the fix from the element that moved

    flowchart TD accTitle: Choosing a fix by cause of the shift accDescr: Start from the element that moved. If it is media, give it dimensions. If it is a late third-party or injected block, reserve a slot or place it where it moves nothing. If text reflowed, fix font loading. If it moved during an animation, use transform. Q["Which element moved, and what arrived just before?"] --> A{"Media (image, video)?"} A -->|"yes"| A1["width and height, or aspect-ratio"] A -->|"no"| B{"Late block: embed, ad, widget, banner?"} B -->|"yes"| B1["Reserve a slot (min-height, aspect-ratio)
    or place it below the viewport"] B -->|"no"| C{"Text reflowed at a font swap?"} C -->|"yes"| C1["font-display: optional or size-adjust,
    preload the font"] C -->|"no"| D{"Moved during an animation?"} D -->|"yes"| D1["Animate transform, not layout properties"] D -->|"no"| E["Look at what inserted content above it:
    user action, or a live update"]

    How to read this: start from the measurement (which element, what loaded just before) and follow one branch. Guessing at images when the culprit is a font is the usual waste.

    Details that matter in practice

    • Fonts. MDN describes font-display: optional as an extremely small block period and no swap period, so a late font is simply not swapped in; swap has an infinite swap period, which means a late swap can reflow text. size-adjust in @font-face scales a font's glyph outlines and metrics, so a fallback can be tuned to the web font's size. Web.dev's guide pairs it with related overrides.
    • Reserved space can be wrong. A min-height of 320 px for a widget that renders 400 px still pushes the content below it by 80 px: smaller than with no reservation, but not zero. Reserve the typical size, or the largest the slot will show.
    • Empty slots. A promo slot that is often empty reserves space that looks like a hole. That is a design decision: ask whether the banner can occupy a fixed strip or sit below the first screen.
    • Off-screen sections. For an element that skips its contents while off screen (for example with content-visibility: auto), MDN's contain-intrinsic-size supplies a placeholder (auto 500px uses 500 px until the real size has been rendered once, then remembers it). MDN notes that when the real size differs from the placeholder, a new layout may be forced as the element scrolls into view, so choose a close value.

    Illustration 2: a realistic product page

    The page loads in four steps: the server HTML with title and price; a hero image; a late reviews widget (a third-party script that injects its block); and a web font. In the starting version the gallery has no dimensions, the reviews block is inserted above the "Add to basket" button with no reserved height, and the font swaps after first paint. A single visit can show three bursts of shifts: image, then reviews, then font. The reserved-space version gives the gallery aspect-ratio: 4 / 3, the widget a min-height, and the heading font font-display: optional with a tuned fallback, and those shifts disappear.

    Illustration 3: when "the user asked for it" still counts

    A "Load more reviews" button fetches the next page. On a slow network the fetch takes 700 ms, and the rows are then inserted above the button the user is looking at, pushing it down.

    The excluding rule is time-based: only shifts within 500 ms of a discrete input are flagged hadRecentInput. Web.dev adds that continuous interactions such as scrolling and dragging are not "recent input". So a response that arrives 700 ms after the click is counted, even though the click caused it, as are shifts caused by a timer or a live update.

    The fix is not to rely on the exclusion. Insert a placeholder of the expected height at the moment of the click (inside the 500 ms window; the real rows then replace it without moving anything), or insert the rows below the button. Then check the largest shift's target in field data.

    Measuring and debugging

    • DevTools. Web.dev's guide says the Performance panel shows shifts in a Layout shifts track, grouping them into clusters; selecting one shows the elements that moved. The live metrics view lets you watch CLS while you interact. Lighthouse identifies shifted elements, but web.dev notes these are often the elements that were pushed rather than the ones that caused the movement, so look at what loaded just before.
    • The API. Each layout-shift entry has value, hadRecentInput, lastInputTime and sources (up to five nodes, sorted by impact area, each with a previous and current rectangle). MDN marks LayoutShift as limited availability and experimental, so feature-detect.
    • In the field. The web-vitals library's onCLS works in Chromium only, per its README. With web-vitals/attribution the callback carries largestShiftTarget (a selector), largestShiftValue, largestShiftTime and loadState (loading, dom-interactive, dom-content-loaded or complete). A large shift in loading points at the initial HTML (images, fonts); one in complete points at late scripts and live updates. Report when the page becomes hidden: the README says the callback is always called then, and web.dev says CLS should be reported when a page is backgrounded.

    Send these fields to your own collector (see the pipeline in the Core Web Vitals lesson), segment by page template and device, and compare windows by cause, not only the total.

    Choosing fixes: trade-offs

    Fix What it prevents Cost or risk
    width and height or aspect-ratio on media Image and video shifts Needs dimensions in the CMS or image pipeline; a wrong ratio still shifts a little
    Reserved slot (min-height) for widgets and ads Late-block shifts Empty space if the slot stays empty; a wrong size shifts
    Placeholder or skeleton Late-content shifts Extra markup; must match the final size
    Load on user action Unexpected insertions One more click; slower perceived flow
    font-display: optional Swap reflow Some visits never see the web font
    Tuned fallback with size-adjust Swap reflow Metrics to maintain per font
    transform animation Animation shifts Layout does not follow the animation; design accordingly
    bfcache eligibility Restored-page shifts Auditing what blocks eligibility

    In practice: a support dashboard with live tickets

    NOTE

    This is a realistic scenario, not a case history. It is a composite built from common patterns, and every number in it is an illustrative assumption.

    The situation

    A support team uses a dashboard: a table of open tickets, a chart of tickets per hour, and a header with a web font. New tickets stream in and are inserted at the top. Agents report clicking the wrong ticket "because the table jumped". Field CLS at p75 on the dashboard template is 0.31 (illustrative), poor, and attribution points at the table rows, mostly with loadState of complete.

    Starting architecture

    Figure 5. Starting architecture: live rows and a chart with no reserved space

    Starting architecture: live rows inserted above, chart and font arrive lateTicket stream to Browser (new rows); Browser to Dashboard API (load data); Dashboard API to Chart widget (series); Browser to Font file (heading font)Browser: rows pushed downBrowserrows pushed downTicket stream: pushes new ticketsTicket streampushes newticketsDashboard API: table and chart dataDashboard APItable and chartdataChart widget: no reserved heightChart widgetno reservedheightFont file: swaps after paintFont fileswaps after paintnew rowsload dataseriesheading font

    How to read this: every arrow into the browser ends in an insertion above or between content the agent is reading. None reserves a size first.

    Options considered

    Option Helps Costs and risk
    Reserve chart height and tune the font fallback Load-time shifts Cheap; does not touch the live rows
    Buffer new rows behind a "3 new tickets" control Live-update shifts The agent clicks to reveal; a ticket can be missed if the control is ignored
    Append new rows at the bottom Shifts for readers at the top Newest-first order is lost
    Pause live updates while the pointer is over the table Wrong-row clicks Hidden state; delayed information

    The decision

    Field Decision record
    Context Field CLS is poor and mostly from live row insertions after load; the chart and font add a smaller load-time share.
    Decision Reserve the chart's height and tune the heading font's fallback. Buffer streamed rows and show only a count pill; the rows are inserted when the agent clicks it.
    Consequences, good Content the agent is reading does not move without an action; load-time shifts disappear.
    Consequences, bad One more click to see new tickets; the buffer logic and its tests are new code.
    Revisit when Field p75 CLS is not under 0.1 two weeks after release, or agents report missed tickets.

    Figure 6. Target architecture: updates are buffered and slots are reserved

    Target architecture: streamed rows wait in a buffer until the agent asks for themTicket stream to Update buffer (new rows); Update buffer to Browser (pill: 3 new); Reserved slots to Browser (sizes known early)Browser: stable layoutBrowserstable layoutTicket stream: pushes new ticketsTicket streampushes newticketsUpdate buffer: counts, no DOM changeUpdate buffercounts, no DOMchangeReserved slots: chart height, font fallbackReserved slotschart height,font fallbacknew rowspill: 3 newsizes known early

    How to read this: the stream no longer writes to the table. Only the pill changes until the agent clicks.

    Rollout and verification

    1. Ship the attribution reporting first with largestShiftTarget and loadState, per template and release, to confirm the table is the cause.
    2. Release the buffered table to a share of agents and compare field CLS for each group.
    3. Test the click path: insert the buffered rows within 500 ms of the click or with a placeholder, and check in the Performance panel that no shift remains.
    4. Add a CI check that fails a build when an <img> lacks dimensions or a reserved slot loses its min-height.

    The 2 a.m. failure

    At 02:00 an outage elsewhere floods the queue and the stream pushes hundreds of tickets in a minute. The buffer works, but the pill's text changes width ("9 new" to "128 new") and shoves the filter controls next to it, so the CLS alert for the template fires. First checks: the attribution largestShiftTarget, the release marker and the stream rate. Mitigation: give the pill a fixed width and cap the text at "99+". Follow-up: add a load test of the stream at an outage rate and a review item for any element whose width depends on live data.

    Architect's notes

    • Make "where will this land" part of every spec for late content: a slot, a size, an owner.
    • Treat dimensions as data, stored with every image in the CMS or image pipeline so the HTML can carry them.
    • Separate load-time from live shifts. The first is fixed by reserving space, the second by design (buffering, user-triggered insertion).
    • Do not lean on the 500 ms exclusion. It is narrow.
    • Keep the element in your field data, with the load state.

    Common mistakes

    • Fixing images when the cause is a font or a widget, because nobody checked which element moved.
    • Reserving a guessed size and never checking it against the real one.
    • Inserting new content above the viewport content on a timer, a stream or a late response.
    • Animating top, left, width or height instead of transform.

    Key takeaways

    • CLS counts visible elements that change start position between frames, not size changes or additions that move nothing else.
    • Score = impact fraction x distance fraction; CLS is the largest session window (gaps under 1 s, at most 5 s), not the sum.
    • Transforms, scrolling, invisible elements and shifts within 500 ms of a discrete input are not counted.
    • Reserve space before content arrives: dimensions or aspect-ratio for media, min-height for slots, placeholders for late content.
    • Fonts shift through metric differences: font-display: optional or a tuned fallback.
    • Live updates need design, not a slot: buffer them or insert them where they move nothing.
    • Measure with the element and the load state so alerts point at the cause.

    Practice exercises

    Exercise 1 — Starter (30 min): Take a page you know, open the Performance panel and find its layout shifts, if any. For each, name the element that moved and what loaded just before it.

    Exercise 2 — Intermediate (2 hours): Build a page with an image above an article. Remove the dimensions, record the shift, compute the score by hand, then add width and height and compare. Try a wrong ratio on purpose and note the remaining shift.

    Exercise 3 — Intermediate (2 hours): Write a function that computes CLS from a list of shifts with session windows and hadRecentInput, and test it with cases at exactly 1,000 ms and 5,000 ms. State where the draft specification does and does not define the rule.

    Exercise 4 — Advanced (half day): Design the "Load more" and "new tickets" interactions so that no inserted content moves what the user is reading, with a timing diagram. Which shifts would still count?

    In this course:

    Elsewhere on the site:

    Official documentation

    Ask about this article

    Get answers grounded in this post. AI-generated — based on this article, and may be imperfect.

    Scaled AI Weekly

    Enjoyed this? Get more like it every Monday.

    Real architecture decisions, LLMOps patterns that survive production, and engineering leadership advice — from 12+ years of building at enterprise scale. Free. No spam. Unsubscribe anytime.

    Get new articles by email

    Free. We email a confirmation link first, and you can unsubscribe from any email.

    Join engineers building production AI systems

    Comments