Learn how to diagnose and fix Cumulative Layout Shift (CLS) performance issues specific to Episerver (Optimizely) CMS and Commerce.
What is CLS?
Cumulative Layout Shift (CLS) measures visual stability by tracking unexpected layout shifts during page load.
CLS Targets
- Good: < 0.1
- Needs Improvement: 0.1 - 0.25
- Poor: > 0.25
Why CLS Matters
- Core Web Vital affecting SEO rankings
- Prevents frustrating user experiences
- Reduces accidental clicks
- Improves engagement and conversions
Measuring CLS on Episerver
Field Data (Real Users)
Google Search Console
- Search Console → Core Web Vitals
- View CLS data for Episerver pages
- Identify problematic page types
Real User Monitoring
// Monitor CLS in production
(function() {
let clsValue = 0;
let clsEntries = [];
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (!entry.hadRecentInput) {
clsValue += entry.value;
clsEntries.push(entry);
console.log('Layout shift:', {
value: entry.value,
sources: entry.sources
});
}
}
});
observer.observe({type: 'layout-shift', buffered: true});
// Send to analytics before page unload
window.addEventListener('beforeunload', () => {
if (typeof gtag !== 'undefined') {
gtag('event', 'cls', {
value: Math.round(clsValue * 1000) / 1000,
event_category: 'Web Vitals',
event_label: clsValue > 0.25 ? 'poor' : clsValue > 0.1 ? 'needs_improvement' : 'good',
non_interaction: true
});
}
});
})();
Lab Data (Testing)
Lighthouse
- Open Chrome DevTools (F12)
- Lighthouse tab
- Run audit
- Review CLS score and sources
Layout Shift Debugger
// Highlight elements causing layout shifts
(function() {
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (!entry.hadRecentInput) {
entry.sources.forEach((source) => {
if (source.node) {
source.node.style.outline = '3px solid red';
console.log('Layout shift source:', source.node);
}
});
}
}
});
observer.observe({type: 'layout-shift', buffered: true});
})();
Common Episerver CLS Issues
1. Images Without Dimensions
Problem: Images from Episerver Media Library load without width/height, causing shifts
Diagnosis:
@* BAD: No dimensions *@
<img src="@Url.ContentUrl(Model.Image)" alt="Image" />
Solutions:
A. Always Specify Dimensions
@{
var image = _contentLoader.Get<ImageData>(Model.Image);
var width = 800;
var height = 600;
var imageUrl = Url.ContentUrl(Model.Image);
}
<img src="@($"{imageUrl}?width={width}")"
alt="@Model.ImageAlt"
width="@width"
height="@height" />
B. Calculate Aspect Ratio
@{
var image = _contentLoader.Get<ImageData>(Model.Image);
// Get original dimensions from metadata
var originalWidth = image.BinaryData?.Thumbnail?.Width ?? 1200;
var originalHeight = image.BinaryData?.Thumbnail?.Height ?? 800;
// Target display width
var displayWidth = 800;
var displayHeight = (int)((double)displayWidth / originalWidth * originalHeight);
}
<img src="@Url.ContentUrl(Model.Image)?width=@displayWidth"
alt="@Model.ImageAlt"
width="@displayWidth"
height="@displayHeight" />
C. Use Aspect Ratio Boxes
<div style="position: relative; padding-bottom: 56.25%; /* 16:9 aspect ratio */">
<img src="@Url.ContentUrl(Model.Image)"
alt="@Model.ImageAlt"
style="position: absolute; top: 0; left: 0; width: 100%; height: 100%; object-fit: cover;" />
</div>
Or with CSS:
.image-container {
position: relative;
aspect-ratio: 16 / 9; /* Modern browsers */
}
.image-container img {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: cover;
}
/* Fallback for older browsers */
@supports not (aspect-ratio: 16 / 9) {
.image-container {
padding-bottom: 56.25%; /* 16:9 */
}
}
<div class="image-container">
<img src="@Url.ContentUrl(Model.Image)" alt="@Model.ImageAlt" />
</div>
D. Create Image Helper Service
public class ImageDimensionService
{
private readonly IContentLoader _contentLoader;
public ImageDimensionService(IContentLoader contentLoader)
{
_contentLoader = contentLoader;
}
public (int width, int height) GetDimensions(ContentReference imageRef, int? targetWidth = null)
{
if (ContentReference.IsNullOrEmpty(imageRef))
return (0, 0);
var image = _contentLoader.Get<ImageData>(imageRef);
var originalWidth = image.BinaryData?.Thumbnail?.Width ?? 1200;
var originalHeight = image.BinaryData?.Thumbnail?.Height ?? 800;
if (!targetWidth.HasValue)
return (originalWidth, originalHeight);
var aspectRatio = (double)originalHeight / originalWidth;
var calculatedHeight = (int)(targetWidth.Value * aspectRatio);
return (targetWidth.Value, calculatedHeight);
}
public string GetAspectRatioStyle(ContentReference imageRef)
{
var (width, height) = GetDimensions(imageRef);
var ratio = (double)height / width * 100;
return $"padding-bottom: {ratio:F2}%;";
}
}
Usage:
@inject ImageDimensionService ImageService
@{
var (width, height) = ImageService.GetDimensions(Model.Image, 800);
}
<img src="@Url.ContentUrl(Model.Image)?width=@width"
alt="@Model.ImageAlt"
width="@width"
height="@height" />
2. Content Areas Loading Asynchronously
Problem: Content areas or blocks load after initial render, shifting content
Solutions:
A. Reserve Space for Content Areas
@model ContentArea
<div class="content-area" style="min-height: 400px;">
@Html.PropertyFor(m => m.MainContentArea)
</div>
Or calculate based on average:
public class ContentAreaHelper
{
public int GetEstimatedHeight(ContentArea contentArea)
{
if (contentArea?.Items == null || !contentArea.Items.Any())
return 0;
// Estimate 300px per block on average
return contentArea.Items.Count() * 300;
}
}
@inject ContentAreaHelper ContentHelper
<div class="content-area"
style="min-height: @(ContentHelper.GetEstimatedHeight(Model.MainContentArea))px;">
@Html.PropertyFor(m => m.MainContentArea)
</div>
B. Use Skeleton Screens
@model ContentArea
@if (Model?.Items?.Any() == true)
{
@Html.PropertyFor(m => m)
}
else
{
<div class="skeleton-content-area">
<div class="skeleton-block"></div>
<div class="skeleton-block"></div>
<div class="skeleton-block"></div>
</div>
}
.skeleton-block {
height: 200px;
margin-bottom: 20px;
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: loading 1.5s infinite;
}
@keyframes loading {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
3. Visitor Group Personalization
Problem: Personalized content appears/changes after page load
Solutions:
A. Server-Side Personalization (Preferred)
@{
var visitor = ServiceLocator.Current.GetInstance<IVisitorGroupRepository>();
var isReturningVisitor = visitor.List()
.Any(vg => vg.Name == "Returning Visitors" && vg.IsMatch(HttpContext.User));
}
@if (isReturningVisitor)
{
<div class="personalized-content">
@Html.PropertyFor(m => m.ReturningVisitorContent)
</div>
}
else
{
<div class="personalized-content">
@Html.PropertyFor(m => m.NewVisitorContent)
</div>
}
B. Same Height for All Variants
<div class="personalized-content" style="min-height: 300px;">
@if (isReturningVisitor)
{
@Html.PropertyFor(m => m.ReturningVisitorContent)
}
else
{
@Html.PropertyFor(m => m.NewVisitorContent)
}
</div>
C. Hide/Show with CSS (No Layout Shift)
<!-- Render both variants -->
<div class="personalized-wrapper">
<div class="variant variant-returning @(isReturningVisitor ? "active" : "hidden")">
@Html.PropertyFor(m => m.ReturningVisitorContent)
</div>
<div class="variant variant-new @(!isReturningVisitor ? "active" : "hidden")">
@Html.PropertyFor(m => m.NewVisitorContent)
</div>
</div>
.personalized-wrapper {
position: relative;
}
.variant {
transition: opacity 0.3s;
}
.variant.hidden {
position: absolute;
opacity: 0;
pointer-events: none;
}
.variant.active {
position: relative;
opacity: 1;
}
4. Dynamic Ads or Embeds
Problem: Ad slots or third-party embeds load without reserved space
Solutions:
A. Reserve Space for Ad Slots
<div class="ad-slot" style="min-height: 250px; min-width: 300px;">
<!-- Ad loads here -->
<div id="ad-container"></div>
</div>
B. Use Fixed-Size Containers
.ad-slot {
width: 300px;
height: 250px;
background: #f5f5f5;
display: flex;
align-items: center;
justify-content: center;
}
.ad-slot::before {
content: 'Advertisement';
color: #999;
font-size: 12px;
}
5. Web Fonts Loading
Problem: Font swap causes text to shift
Solutions:
A. Use font-display: optional
@font-face {
font-family: 'CustomFont';
src: url('/fonts/custom-font.woff2') format('woff2');
font-display: optional; /* Prevents layout shift */
}
B. Match Fallback Font Metrics
body {
font-family: 'CustomFont', Arial, sans-serif;
/* Adjust fallback to match custom font */
font-size: 16px;
line-height: 1.5;
}
/* Before custom font loads */
body:not(.fonts-loaded) {
font-size: 15.8px; /* Adjust to match custom font metrics */
letter-spacing: 0.01em;
}
// Mark when fonts loaded
document.fonts.ready.then(() => {
document.body.classList.add('fonts-loaded');
});
C. Preload Critical Fonts
<head>
<link rel="preload"
href="/fonts/custom-font.woff2"
as="font"
type="font/woff2"
crossorigin />
</head>
6. Episerver Forms
Problem: Forms render progressively, causing shifts
Solutions:
A. Reserve Minimum Height
@model FormContainerBlock
<div class="episerver-form-container" style="min-height: 500px;">
@Html.PropertyFor(m => m.Form)
</div>
B. Use CSS Grid with Fixed Rows
.episerver-form {
display: grid;
grid-template-rows: repeat(auto-fill, minmax(60px, auto));
gap: 20px;
}
.form-field {
min-height: 60px;
}
7. Commerce Product Listings
Problem: Product grids shift as images load
Solutions:
A. Fixed Aspect Ratio for Product Images
@model IEnumerable<ProductViewModel>
<div class="product-grid">
@foreach (var product in Model)
{
<div class="product-card">
<div class="product-image-container">
<img src="@Url.ContentUrl(product.Image)?width=400"
alt="@product.Name"
width="400"
height="400" />
</div>
<h3>@product.Name</h3>
<p class="price">@product.Price.ToString("C")</p>
</div>
}
</div>
.product-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 20px;
}
.product-image-container {
position: relative;
aspect-ratio: 1 / 1;
background: #f5f5f5;
overflow: hidden;
}
.product-image-container img {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: cover;
}
8. Cookie Consent Banners
Problem: Cookie banner pushes content down
Solutions:
A. Use Fixed or Sticky Positioning
.cookie-banner {
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: 1000;
background: #fff;
padding: 20px;
box-shadow: 0 -2px 10px rgba(0,0,0,0.1);
}
B. Reserve Space at Bottom
body {
padding-bottom: 100px; /* Height of cookie banner */
}
body.cookie-accepted {
padding-bottom: 0;
}
Episerver-Specific Optimizations
1. Optimize Block Rendering
public class StableBlockRenderer : BlockRenderer
{
protected override void RenderBlock(
HtmlHelper htmlHelper,
ContentAreaItem contentAreaItem,
IContent content)
{
// Add min-height to block container
htmlHelper.ViewContext.Writer.Write(
$"<div class='block-container' style='min-height: {GetEstimatedHeight(content)}px'>");
base.RenderBlock(htmlHelper, contentAreaItem, content);
htmlHelper.ViewContext.Writer.Write("</div>");
}
private int GetEstimatedHeight(IContent content)
{
// Estimate based on block type
return content switch
{
HeroBlockType => 400,
TextBlockType => 200,
ImageBlockType => 300,
_ => 250
};
}
}
2. Preload Critical Content Area Blocks
@{
var firstBlock = Model.MainContentArea?.Items?.FirstOrDefault();
if (firstBlock != null)
{
var blockContent = _contentLoader.Get<IContent>(firstBlock.ContentLink);
if (blockContent is HeroBlockType hero && hero.BackgroundImage != null)
{
<link rel="preload"
as="image"
href="@Url.ContentUrl(hero.BackgroundImage)?width=1920" />
}
}
}
3. Stable Commerce Cart
<div class="cart-summary" style="min-height: 60px;">
@if (Model.Cart?.GetAllLineItems().Any() == true)
{
<span class="cart-count">@Model.Cart.GetAllLineItems().Sum(li => li.Quantity)</span>
<span class="cart-total">@Model.Cart.GetTotal().Amount.ToString("C")</span>
}
else
{
<span class="cart-empty">Cart is empty</span>
}
</div>
4. Prevent Shifts from Episerver Tracking
// Ensure tracking scripts don't cause layout shifts
(function() {
// Load Episerver Tracking asynchronously
var script = document.createElement('script');
script.src = '/episerver/tracking/script.js';
script.async = true;
script.defer = true;
document.head.appendChild(script);
})();
Testing CLS Fixes
Visual Regression Testing
// Capture layout shift data during automated tests
const { chromium } = require('playwright');
(async () => {
const browser = await chromium.launch();
const page = await browser.newPage();
let clsValue = 0;
await page.evaluateOnNewDocument(() => {
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (!entry.hadRecentInput) {
window.clsValue = (window.clsValue || 0) + entry.value;
}
}
}).observe({type: 'layout-shift', buffered: true});
});
await page.goto('https://your-episerver-site.com');
await page.waitForLoadState('networkidle');
clsValue = await page.evaluate(() => window.clsValue);
console.log('CLS:', clsValue);
await browser.close();
})();
Manual Testing Checklist
- Test on mobile (viewport: 375x667)
- Test on tablet (viewport: 768x1024)
- Test on desktop (viewport: 1920x1080)
- Test with slow 3G connection
- Test with cache cleared
- Test all page types
- Test personalized content
- Test with ad blockers disabled
CLS Monitoring Dashboard
// Send CLS data to analytics for monitoring
(function() {
let clsValue = 0;
let sessionId = Date.now();
let pageType = document.body.dataset.pageType || 'unknown';
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (!entry.hadRecentInput) {
clsValue += entry.value;
// Send each shift to analytics
if (typeof gtag !== 'undefined') {
gtag('event', 'layout_shift', {
value: entry.value,
cumulative_value: clsValue,
page_type: pageType,
session_id: sessionId,
event_category: 'Web Vitals',
non_interaction: true
});
}
}
}
});
observer.observe({type: 'layout-shift', buffered: true});
// Send final CLS on page hide
window.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden' && typeof gtag !== 'undefined') {
gtag('event', 'cls_final', {
value: Math.round(clsValue * 1000) / 1000,
page_type: pageType,
event_category: 'Web Vitals',
event_label: clsValue > 0.25 ? 'poor' : clsValue > 0.1 ? 'needs_improvement' : 'good',
non_interaction: true
});
}
});
})();
Common CLS Pitfalls
- Testing Only Desktop: Always test mobile where CLS is often worse
- Fast Connections: Test on slow connections to see shifts
- Cached Testing: Clear cache to see real user experience
- Single Page Type: Test all page types (home, product, article, etc.)
- Edit Mode: Always test in view mode, not edit mode
- Static Content: Test with dynamic/personalized content
Checklist
- All images have width and height attributes
- Content areas have reserved space
- Fonts use font-display: optional or swap
- Ad slots have fixed dimensions
- Cookie banner uses fixed positioning
- Product images have aspect ratios
- Personalized content has stable dimensions
- Forms have minimum heights
- Test on slow 3G connection
- Monitor CLS in production
Next Steps
- LCP Optimization - Fix slow loading
- Troubleshooting Overview - Other common issues
- Web Vitals - Learn more about Core Web Vitals