Fix Episerver (Optimizely) CLS Issues | OpsBlu Docs

Fix Episerver (Optimizely) CLS Issues

Stabilize Episerver layouts by reserving content area blocks, preloading fonts, and controlling Optimizely personalization container shifts.

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

  1. Search ConsoleCore Web Vitals
  2. View CLS data for Episerver pages
  3. 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

  1. Open Chrome DevTools (F12)
  2. Lighthouse tab
  3. Run audit
  4. 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;
}

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

  1. Testing Only Desktop: Always test mobile where CLS is often worse
  2. Fast Connections: Test on slow connections to see shifts
  3. Cached Testing: Clear cache to see real user experience
  4. Single Page Type: Test all page types (home, product, article, etc.)
  5. Edit Mode: Always test in view mode, not edit mode
  6. 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

Additional Resources