Optimizing CLS: Unsized Images, Late Content, Font Swaps and @defer Placeholders
To lower CLS, reserve the space of every piece of late content from the start: sized images, placeholders at their final size and skeletons. We measure the impact × distance calculation of the "Similar books" block added to Bookstore's book page, show that @defer (on viewport) moves the shift to the moment the user scrolls (0.1079 on a phone), and bring CLS to 0 in every scenario with a slot, a skeleton and a margin-collapse fix.
Version note: The examples in this post were run with Angular 22.2 (with TypeScript 6.0, the version Angular 22.2 supports), web-vitals 6.2.3, .NET 10 (ASP.NET Core 10) and Playwright 1.65. All numbers are lab measurements taken on a single machine against a local build with headless Chromium 158. Browser support data comes from MDN browser-compat-data 8.1.9 (2026-11-12).
Predict First
We add a "Similar books" block to the book detail page of Bookstore, the online bookstore used as the shared example across this site's series: four books from the same category, between the book's info and the "Details" section. The block fetches its own data from a separate endpoint:
books.MapGet("/{isbn}/similar", Results<Ok<IEnumerable<Book>>, NotFound> (string isbn, int? count, BookCatalog catalog) =>
catalog.ByIsbn.TryGetValue(isbn, out Book? book)
? TypedResults.Ok(catalog.Similar(book, Math.Clamp(count ?? 4, 1, 8)))
: TypedResults.NotFound());
template: `
@if (similar.value(); as books) {
<h2>Similar books</h2>
<ul class="cards">
@for (book of books; track book.isbn) {
<li>
<a [routerLink]="['/books', book.isbn]">{{ book.title }}</a>
<span>{{ book.author }}</span>
<span class="price">{{ book.price | currency: book.currency }}</span>
</li>
}
</ul>
}
The book page places the block right below the book's info once the book itself has arrived. Predict: what CLS does the page get while loading, on desktop and on a phone? If we defer the block's JavaScript with @defer (on viewport), does it get better or worse?
How a Shift Is Scored
The browser treats every visible element whose position changes between two frames as an unstable element and computes a layout shift score for that frame: the impact fraction times the distance fraction. The impact fraction is the combined area that the unstable elements' old and new positions cover in the viewport, divided by the viewport area. The distance fraction is the largest horizontal or vertical distance any element moved, divided by the viewport's larger side. Adding a new element to the page is not a shift by itself; the shift comes from the existing elements it pushes out of place.
CLS is not the plain sum of these scores. Following web.dev's definition, shifts are grouped into session windows: shifts less than 1 second apart belong to the same window, and a window lasts at most 5 seconds. The page's CLS is the total of the largest window, so a page that stays open for hours does not collect a bad score from small shifts. The second rule is about user input: shifts within 500 ms after a click, tap or key press arrive with the hadRecentInput flag and are not counted. Content moving below a menu the user just opened is an expected shift. Scrolling does not fall under this exception. Thresholds are the same as in the first post: 0.1 or less is "good", above 0.25 is "poor", at p75.
To see this calculation in measurements we added cls-trace.mjs to the lab. The script collects layout-shift entries with a PerformanceObserver, prints for each shift the elements that moved with their old and new rectangles, computes impact and distance for the largest shift itself and splits the shifts into session windows:
await page.addInitScript(() => {
window.__shifts = [];
new PerformanceObserver((list) => {
for (const e of list.getEntries()) {
window.__shifts.push({
time: Math.round(e.startTime),
value: e.value,
hadRecentInput: e.hadRecentInput,
sources: e.sources.map((s) => ({
node: s.node ? s.node.nodeName.toLowerCase() + (s.node.className ? '.' + String(s.node.className).split(' ')[0] : '') : '(removed)',
prev: [s.previousRect.x, s.previousRect.y, s.previousRect.width, s.previousRect.height].map(Math.round),
curr: [s.currentRect.x, s.currentRect.y, s.currentRect.width, s.currentRect.height].map(Math.round),
})),
});
}
}).observe({ type: 'layout-shift', buffered: true });
});
await page.goto(positionals[0], { waitUntil: 'networkidle', timeout: 120_000 });
// A user who reads on: scroll down in steps of 400 px (a wheel scroll is not "recent input")
for (let i = 0; i < Number(values.scroll); i++) {
await page.mouse.wheel(0, 400);
await page.waitForTimeout(600);
}
if (values.click) {
await page.locator(values.click).click();
}
The LayoutShift entry and its sources list exist only in Chromium; according to browser-compat-data, Firefox and Safari have no layout shift API, so RUM data from those browsers contains no CLS at all.
A Reminder First: the Unsized Cover
In the image and font post we brought the cover's CLS on the book page down to 0 with the width and height that NgOptimizedImage requires; p75 in the field simulation had been 0.32. For a single run we turned the cover on the same page back into an unsized <img> and traced it with the phone profile:
viewport 412x839
time(ms) score recentInput element: previous [x,y,w,h] -> current
1612 0.0000 false nav: [135,15,58,24] -> [140,15,58,24]
1656 0.5422 false div: [16,94,380,253] -> [16,664,380,175]
div.similar-slot: [16,347,380,296] -> [0,0,0,0]
section.details: [16,662,380,177] -> [0,0,0,0]
largest shift at 1656 ms: impact 0.7981 x distance 0.6794 = 0.5422 (browser: 0.5422)
session window 1: 1612-1656 ms, 2 shift(s), 0.5422
CLS = largest session window = 0.5422; excluded (recent input): 0
The image's size becomes known as soon as the start of the file arrives, and the zero-height <img> jumps to 380×570 at once. The text block below it moves 570 px down: its old and new positions cover about 80% of the viewport, and the distance is 570/839. A single shift scores 0.54, which is "poor". The script's calculation (0.7981 × 0.6794) matches the browser's value. The [0,0,0,0] in the second and third lines are elements pushed out of the viewport; their area does not count. The nav shift at the top is the font swap: when Inter loads, the "Bookstore" logo gets slightly wider and the link next to it moves 5 px to the right. Its score is 0 to four decimals because the element is small and moves little; the fallback metrics from the image and font post do their job.
The Similar Books Block Pushes the Page
Now the real topic. With a sized cover, the first version of the block (A) on desktop:
viewport 1350x940
time(ms) score recentInput element: previous [x,y,w,h] -> current
364 0.0000 false nav: [135,15,58,24] -> [140,15,58,24]
407 0.0385 false section.details: [141,569,1068,234] -> [141,735,1068,205]
largest shift at 407 ms: impact 0.3122 x distance 0.1230 = 0.0384 (browser: 0.0385)
session window 1: 364-407 ms, 2 shift(s), 0.0385
CLS = largest session window = 0.0385; excluded (recent input): 0
The book's data arrives, the page renders, a second request goes out for "Similar books", and when its response arrives (at 407 ms) the block takes 166 px. The "Details" section below it moves from 569 to 735; impact 0.31, distance 166/1350 = 0.123, score 0.0385. The same in all five runs:
http://127.0.0.1:5300/books/9781000000009 profile=desktop runs=5
run TTFB FCP LCP CLS
1 41 276 396 0.0385
2 41 276 396 0.0385
3 41 284 396 0.0385
4 41 272 392 0.0385
5 41 292 412 0.0385
med 41 276 396 0.0385
LCP subparts (median): ttfb=41 loadDelay=284 loadDuration=54 renderDelay=17
LCP element: html>body>app-root>main>app-book-page>article.book>img
0.0385 is below the "good" threshold, but one block spends a third of the budget, and on a real page a cookie banner, an ad or a late campaign strip joins the same session window. On a phone the same load gives CLS 0: the cover fills most of the screen, "Details" is below the viewport at load time, and shifts of elements that are not visible do not count.
When the @defer Placeholder Is Not the Real Size
The block sits low on the page, so moving its code and its request out of the initial load is a natural step. Angular's @defer (on viewport) block does not download the component's chunk until the placeholder enters the viewport (B):
@defer (on viewport) {
<app-similar-books [isbn]="b.isbn" />
} @placeholder {
<p>Loading similar books...</p>
}
In the build output similar-books is now a 1.54 KB lazy chunk. On desktop CLS went from 0.0385 to 0.0453, because there are now two shifts: "Details" moves 40 px up when the placeholder is removed and 166 px down when the block arrives. On a phone the load measurement is still 0. But users do not just open a page and wait; they read. We added a --scroll option to measure.mjs that performs three 400 px wheel scrolls after the load:
viewport 412x839
time(ms) score recentInput element: previous [x,y,w,h] -> current
1604 0.0000 false nav: [135,15,58,24] -> [140,15,58,24]
3505 0.0139 false section.details: [16,613,380,226] -> [16,573,380,234]
3673 0.0940 false section.details: [16,536,380,234] -> [16,819,380,20]
largest shift at 3673 ms: impact 0.2792 x distance 0.3373 = 0.0942 (browser: 0.0940)
session window 1: 1604-1604 ms, 1 shift(s), 0.0000
session window 2: 3505-3673 ms, 2 shift(s), 0.1079
CLS = largest session window = 0.1079; excluded (recent input): 0
When the placeholder enters the viewport the chunk downloads, the request goes out, and the block pushes the "Details" section the user is reading at that moment 283 px down. CLS 0.1079, "needs improvement". A lab measurement that only looks at the load said 0 for this page. In version A the same scroll scenario gives 0, because the block had arrived before the user scrolled: @defer did not remove the shift, it moved it to the moment the user sees it. Angular's @defer guide also warns that deferring content visible on the initial load can increase CLS.
To tell this kind of shift apart in RUM we added two fields to the rum.ts from the first post:
case 'CLS':
return {
target: metric.attribution.largestShiftTarget,
largestShift: round(metric.attribution.largestShiftValue ?? 0, 4),
// When it happened: loadState 'complete' means after the load event (scroll, late content)
time: round(metric.attribution.largestShiftTime ?? 0),
loadState: metric.attribution.loadState,
};
{
"name": "CLS",
"value": 0.1079,
"rating": "needs-improvement",
"device": "mobile",
"navigationType": "navigate",
"attribution": {
"target": "html>body>app-root>main>app-book-page>section.details",
"largestShift": 0.094,
"time": 3673,
"loadState": "complete"
},
"route": "/books/:isbn"
}
loadState: "complete" together with a time of 3.7 s says the shift happened after the page had loaded. In field data this pair usually means scrolling or late content, and the lab needs that scenario too.
Reserving Space: Slot, Skeleton and Margin Collapse
The fix is to reserve the space of the block's final state from the start. The cards have a fixed height (104 px) and the heading's height is known, so we put the block inside a slot with a fixed min-height. The placeholder, the skeleton cards shown until the data arrives and the real cards all fit into the same space (C1). The first attempt was not exactly zero on desktop:
viewport 1350x940
time(ms) score recentInput element: previous [x,y,w,h] -> current
334 0.0000 false nav: [135,15,58,24] -> [140,15,58,24]
367 0.0058 false section.details: [141,736,1068,204] -> [141,760,1068,180]
div.similar-slot: [141,550,1068,167] -> [141,574,1068,167]
417 0.0035 false section.details: [141,760,1068,180] -> [141,791,1068,149]
largest shift at 367 ms: impact 0.3282 x distance 0.0178 = 0.0058 (browser: 0.0058)
session window 1: 334-417 ms, 3 shift(s), 0.0094
CLS = largest session window = 0.0094; excluded (recent input): 0
Two small shifts remained, and both are classics. At 367 ms the slot itself moves 24 px down: the 24 px margin-top of the h2, the component's first element, collapses through the host element and the slot, which have no padding or border, and pushes the slot; this is margin collapsing. At 417 ms the "Show more" button appears below the cards once the data arrives and pushes "Details" another 31 px. The final version (C2) closes both: padding-top instead of margin-top on the host, and the button in the same fixed-height row as the heading:
template: `
<!-- Heading and button share one fixed-height row: the button appearing later moves nothing -->
<div class="head">
<h2>Similar books</h2>
@if (!showAll() && (similar.value()?.length ?? 0) > 4) {
<button type="button" class="more" (click)="showAll.set(true)">Show more</button>
}
</div>
<ul class="cards">
@for (book of visible(); track book.isbn) {
<li>
<a [routerLink]="['/books', book.isbn]">{{ book.title }}</a>
<span>{{ book.author }}</span>
<span class="price">{{ book.price | currency: book.currency }}</span>
</li>
} @empty {
<!-- Skeleton while loading: same cards, same height, nothing moves when the data arrives -->
@for (i of skeleton; track i) {
<li class="skeleton" aria-hidden="true"></li>
}
}
</ul>
`,
styles: `
/* padding instead of the heading's margin-top: a child's top margin collapses through the slot */
:host { display: block; padding-top: 24px; }
.head { display: flex; align-items: center; justify-content: space-between; height: 40px; margin-bottom: 12px; }
h2 { margin: 0; font-size: 22px; }
.cards { list-style: none; padding: 0; margin: 0; display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; }
.cards li { display: flex; flex-direction: column; gap: 4px; height: 104px; padding: 12px;
border: 1px solid #e2e8f0; border-radius: 8px; background: #fff; overflow: hidden; }
.cards li.skeleton { background: #e2e8f0; }
.price { font-weight: 700; margin-top: auto; }
.more { padding: 6px 12px; font: inherit; }
@media (max-width: 640px) { .cards { grid-template-columns: repeat(2, 1fr); } }
`,
})
export class SimilarBooks {
readonly isbn = input.required<string>();
protected readonly similar = httpResource<Book[]>(() => `/api/books/${this.isbn()}/similar?count=8`);
// 4 cards at first, the other 4 behind "Show more"
protected readonly showAll = signal(false);
protected readonly skeleton = [1, 2, 3, 4];
<!-- The slot keeps the block's final height: placeholder, skeleton and cards all fit in it -->
<div class="similar-slot">
@defer (on viewport) {
<app-similar-books [isbn]="b.isbn" />
} @placeholder {
<div></div>
}
</div>
/* 24 px padding + 40 px heading row + 12 px gap + one row of 104 px cards; two rows on narrow screens */
.similar-slot { min-height: 180px; }
.details dl { display: grid; grid-template-columns: 140px 1fr; gap: 8px 16px; }
.details dt { color: #64748b; }
.details dd { margin: 0; }
@media (max-width: 640px) {
.book { grid-template-columns: 1fr; }
.similar-slot { min-height: 296px; }
}
http://127.0.0.1:5300/books/9781000000009 profile=desktop runs=5
run TTFB FCP LCP CLS
1 41 268 372 0
2 41 276 392 0
3 41 276 372 0
4 41 280 392 0
5 41 268 372 0
med 41 276 372 0
LCP subparts (median): ttfb=41 loadDelay=276 loadDuration=50 renderDelay=13
LCP element: html>body>app-root>main>app-book-page>article.book>img
http://127.0.0.1:5300/books/9781000000009 profile=mobile runs=5
run TTFB FCP LCP CLS
1 151 1336 1904 0
2 151 1352 1916 0
3 151 1340 1908 0
4 150 1332 1904 0
5 151 1336 1904 0
med 151 1336 1904 0
LCP subparts (median): ttfb=151 loadDelay=1349 loadDuration=387 renderDelay=18
LCP element: html>body>app-root>main>app-book-page>article.book>img
| Version | Desktop, load | Phone, load | Phone, load + scroll |
|---|---|---|---|
| A: block, no space reserved | 0.0385 | 0 | 0 |
B: A + @defer (on viewport), small placeholder | 0.0453 | 0 | 0.1079 |
C1: slot with min-height + skeleton | 0.0094 | 0 | 0.0101 |
C2: C1 + padding-top, button in the heading row | 0 | 0 | 0 |
A fixed min-height is a number that must be updated when the content changes, which is why the CSS says where it comes from. For a list whose number of rows varies, aspect-ratio or a fixed height per card is more robust. For long, off-screen parts of a page there is one more option: content-visibility: auto together with contain-intrinsic-size gives the section that is not rendered an estimated size; if the size is wrong, you get shifts and a jumping scrollbar while scrolling. We did not measure this in this post.
The 500 ms Exception: Shifts the User Asked For Do Not Count
The "Show more" button opens four more cards and pushes "Details" 116 px. This is an intended shift; the user clicked and expects the content to grow:
viewport 1350x940
time(ms) score recentInput element: previous [x,y,w,h] -> current
335 0.0000 false nav: [135,15,58,24] -> [140,15,58,24]
1553 0.0138 true section.details: [141,749,1068,191] -> [141,865,1068,75]
largest shift at 335 ms: impact 0.0012 x distance 0.0037 = 0.0000 (browser: 0.0000)
session window 1: 335-335 ms, 1 shift(s), 0.0000
CLS = largest session window = 0.0000; excluded (recent input): 1
The shift after the click scores 0.0138, but it has hadRecentInput: true and does not enter CLS. The rule is limited to 500 ms: if the button requested the new content from the server and the response arrived later than half a second on a slow phone, the same shift would count. Reserving space immediately on click (a skeleton) or loading the content in advance is therefore the safe side.
Other Typical Sources and the Angular-Specific Ones
Late banners, cookie notices and ad slots are fixed the same way: either reserve the space up front or show the content outside the existing flow (a bar pinned to the bottom of the page pushes nothing). For ads and embeds of unknown height, reserving space for the most common height works better than leaving no space. In animations, changing top, left, height or margin affects layout and produces layout shifts; an animation done with transform does not change layout and is not counted.
On the Angular side there are three sources. The first is the one in this post: the @defer placeholder and the @loading block must be the same size as the content that replaces them. The second is HTML coming from SSR or prerendering that changes during hydration. A header that says "0 books in your cart" on the server and grows with the cart read from localStorage in the browser counts as a shift. The third is time zones: a date formatted in UTC on the server and rendered again in the user's local time in the browser can change the text length and the line breaks. In both cases the fix is for the server and the browser to render the same thing: show the user-specific part in a fixed-size area, or format the date with the same time zone on both sides. Hydration details are in the Angular SSR post.
Production Notes
Only Chromium measures CLS; you cannot see shifts for Safari and Firefox users in RUM. On a site with many iPhone users, Chrome's narrow-viewport Android data is the closest indicator. Store attribution.target, time and loadState next to CLS in RUM and group the worst pages by these three: loading and dom-interactive point to shifts during the first render, complete to late content or scrolling. The element in the selector (section.details) is the one being pushed; you need to look at what appeared above it.
Build the lab scenario from field data. In this post the desktop load, the phone load and scrolling on a phone gave three different results; the loadState and the device mix in RUM tell you which one matches real users. Finally, CLS covers the whole life of the page: in SPAs, route changes are not new page loads, and shifts are attributed to the route that was opened first. Soft navigation support will change this; the reportSoftNavs option we discussed in the first post applies here too.
Common Pitfalls
- Measuring CLS only during the load. A
@defer (on viewport)block or a lazy image triggered by scrolling can give 0 at load and above 0.1 for the user. - Deferring a visible section with
@deferand leaving a small placeholder. Deferring does not remove the shift; it changes when it happens. - An
<img>withoutwidth/height. A single cover produced 0.54 on this page. - Making the skeleton "about" the right size. A difference of a few pixels or a
margin-topthat collapses outward is still a shift. - Forgetting small elements that appear after the data arrives, such as a button, a counter or a "New" label.
- Animating with
top/left/height; usetransform. - Fetching content from the server on a click and showing it later than 500 ms; once the exception window is over, the shift counts.
A Small Experiment
★☆☆ Run cls-trace.mjs against the final version with --profile lowend --scroll 3. Does any shift happen when you scroll while the skeleton cards are visible?
★★☆ Change the "Show more" button so that it requests the remaining four books from the API when clicked (tie the ?count=8 request to the click). With --profile lowend, look at the hadRecentInput value of the shift after the click; does the response arrive later than 500 ms?
★★★ Show a date on the book detail page ("Added on ...") and format it with DatePipe in the user's time zone. Prerender this page like the home page, change the browser's time zone with Playwright's timezoneId option, and measure whether the text changes after hydration and how that affects CLS.
CLS on the book page is 0 in every scenario, but we saw a side effect of version B in the build output: with the first lazy chunk, esbuild moved the shared code into a separate chunk, and the JavaScript downloaded at startup is still around 380 KB. In the next post we look at the cost of JavaScript: we will move the CDK and the catalog page out of the initial load with lazy routes, and use @defer triggers, Angular 22's injectAsync and bundle budgets while measuring each step.