Fix Episerver (Optimizely) LCP Issues | OpsBlu Docs

Fix Episerver (Optimizely) LCP Issues

Improve Episerver LCP by tuning .NET output caching, optimizing content area rendering, and using Optimizely's CDN for media.

Learn how to diagnose and fix Largest Contentful Paint (LCP) performance issues specific to Episerver (Optimizely) CMS and Commerce.

What is LCP?

Largest Contentful Paint (LCP) measures how long it takes for the largest content element to become visible in the viewport.

LCP Targets

  • Good: < 2.5 seconds
  • Needs Improvement: 2.5 - 4.0 seconds
  • Poor: > 4.0 seconds

Why LCP Matters

  • Core Web Vital affecting SEO rankings
  • Directly impacts user experience
  • Influences conversion rates
  • Used by Google for page ranking

Measuring LCP on Episerver

Field Data (Real User Metrics)

Google Search Console

  1. Go to Search ConsoleCore Web Vitals
  2. View LCP data for your Episerver site
  3. Identify problematic page types

Chrome User Experience Report

// Add to your site to monitor real-user LCP
new PerformanceObserver((list) => {
  const entries = list.getEntries();
  const lastEntry = entries[entries.length - 1];

  console.log('LCP:', lastEntry.renderTime || lastEntry.loadTime);

  // Send to analytics
  gtag('event', 'web_vitals', {
    name: 'LCP',
    value: Math.round(lastEntry.renderTime || lastEntry.loadTime),
    event_category: 'Web Vitals'
  });
}).observe({entryTypes: ['largest-contentful-paint']});

Lab Data (Testing)

PageSpeed Insights

  1. Visit PageSpeed Insights
  2. Enter your Episerver page URL
  3. Review LCP timing and suggestions

Lighthouse

# Run Lighthouse CLI
lighthouse https://your-episerver-site.com --view

Or use Chrome DevTools:

  1. Open DevTools (F12)
  2. Go to Lighthouse tab
  3. Run analysis
  4. Review LCP section

WebPageTest

  1. Go to WebPageTest
  2. Enter URL and location
  3. Review "Largest Contentful Paint" metric
  4. Check filmstrip view to identify LCP element

Common Episerver LCP Issues

1. Large Hero Images from Media Library

Problem: Unoptimized hero images loaded from Episerver Media Library

Diagnosis:

@* Check image size in Network tab *@
<img src="@Url.ContentUrl(Model.HeroImage)" alt="Hero" />

Solutions:

A. Use ImageResizer (Built-in)

@{
    var imageUrl = Url.ContentUrl(Model.HeroImage);
    var optimizedUrl = $"{imageUrl}?width=1920&quality=80&format=webp";
}

<img src="@optimizedUrl"
     srcset="@($"{imageUrl}?width=640&quality=80&format=webp") 640w,
             @($"{imageUrl}?width=1280&quality=80&format=webp") 1280w,
             @($"{imageUrl}?width=1920&quality=80&format=webp") 1920w"
     sizes="100vw"
     alt="@Model.HeroImageAlt"
     width="1920"
     height="1080" />

B. Preload LCP Image

@{
    var heroImageUrl = Url.ContentUrl(Model.HeroImage);
    var optimizedHeroUrl = $"{heroImageUrl}?width=1920&quality=80&format=webp";
}

<head>
    <!-- Preload hero image -->
    <link rel="preload"
          as="image"
          href="@optimizedHeroUrl"
          imagesrcset="@($"{heroImageUrl}?width=640&quality=80&format=webp") 640w,
                       @($"{heroImageUrl}?width=1280&quality=80&format=webp") 1280w,
                       @($"{heroImageUrl}?width=1920&quality=80&format=webp") 1920w"
          imagesizes="100vw" />
</head>

C. Custom Image Service

public class OptimizedImageService
{
    private readonly IContentLoader _contentLoader;

    public OptimizedImageService(IContentLoader contentLoader)
    {
        _contentLoader = contentLoader;
    }

    public string GetOptimizedImageUrl(ContentReference imageRef, int width, int quality = 80, string format = "webp")
    {
        if (ContentReference.IsNullOrEmpty(imageRef))
            return string.Empty;

        var image = _contentLoader.Get<ImageData>(imageRef);
        var baseUrl = image.BinaryData?.ToString() ?? string.Empty;

        return $"{baseUrl}?width={width}&quality={quality}&format={format}";
    }

    public string GetResponsiveImageSrcSet(ContentReference imageRef, int[] widths, int quality = 80)
    {
        return string.Join(", ",
            widths.Select(w => $"{GetOptimizedImageUrl(imageRef, w, quality)} {w}w"));
    }
}

Usage:

@inject OptimizedImageService ImageService

<img src="@ImageService.GetOptimizedImageUrl(Model.HeroImage, 1920)"
     srcset="@ImageService.GetResponsiveImageSrcSet(Model.HeroImage, new[] { 640, 1280, 1920 })"
     sizes="100vw"
     alt="@Model.HeroImageAlt"
     width="1920"
     height="1080" />

2. Render-Blocking Resources

Problem: JavaScript and CSS in <head> delay rendering

Diagnosis: Check Lighthouse "Eliminate render-blocking resources"

Solutions:

A. Defer Non-Critical JavaScript

<!-- BAD: Blocking script -->
<script src="/Scripts/analytics.js"></script>

<!-- GOOD: Deferred script -->
<script src="/Scripts/analytics.js" defer></script>

<!-- GOOD: Async script -->
<script src="/Scripts/tracking.js" async></script>

B. Use Episerver Client Resources

[ModuleDependency(typeof(EPiServer.Web.InitializationModule))]
public class ClientResourceInitialization : IInitializableModule
{
    public void Initialize(InitializationEngine context)
    {
        var service = context.Locate.Advanced.GetInstance<IClientResourceService>();

        // Load scripts at footer (non-blocking)
        service.RequireScript("/Scripts/app.js").AtFooter();
        service.RequireScript("/Scripts/tracking.js").AtFooter();
    }

    public void Uninitialize(InitializationEngine context) { }
}

C. Inline Critical CSS

@{
    var criticalCss = @"
        body { margin: 0; font-family: sans-serif; }
        .hero { min-height: 400px; background: #f5f5f5; }
        /* Other critical styles */
    ";
}

<head>
    <!-- Inline critical CSS -->
    <style>@Html.Raw(criticalCss)</style>

    <!-- Defer non-critical CSS -->
    <link rel="preload" href="/Styles/main.css" as="style"
    <noscript><link rel="stylesheet" href="/Styles/main.css"></noscript>
</head>

D. Extract and Inline Critical CSS

Use tools like Critical:

// gulpfile.js or build script
const critical = require('critical');

critical.generate({
  inline: true,
  base: 'wwwroot/',
  src: 'index.html',
  target: {
    html: 'index-critical.html',
    css: 'critical.css'
  },
  width: 1300,
  height: 900
});

3. Slow Server Response Time (TTFB)

Problem: Server takes too long to respond

Diagnosis:

// Add server timing header
public class ServerTimingFilter : ActionFilterAttribute
{
    private Stopwatch _stopwatch;

    public override void OnActionExecuting(ActionExecutingContext filterContext)
    {
        _stopwatch = Stopwatch.StartNew();
    }

    public override void OnResultExecuted(ResultExecutedContext filterContext)
    {
        _stopwatch.Stop();
        var response = filterContext.HttpContext.Response;
        response.Headers.Add("Server-Timing", $"total;dur={_stopwatch.ElapsedMilliseconds}");
    }
}

Solutions:

A. Enable Output Caching

[ContentOutputCache(Duration = 3600)] // Cache for 1 hour
public class ArticlePageController : PageController<ArticlePageType>
{
    public ActionResult Index(ArticlePageType currentPage)
    {
        return View(currentPage);
    }
}

Or in web.config:

<system.webServer>
  <caching>
    <profiles>
      <add extension=".html" policy="CacheUntilChange" kernelCachePolicy="CacheUntilChange" />
    </profiles>
  </caching>
</system.webServer>

B. Implement Content Delivery API Cache

public class CachedContentDeliveryService
{
    private readonly IContentLoader _contentLoader;
    private readonly IMemoryCache _cache;

    public CachedContentDeliveryService(IContentLoader contentLoader, IMemoryCache cache)
    {
        _contentLoader = contentLoader;
        _cache = cache;
    }

    public T GetContent<T>(ContentReference contentRef) where T : IContent
    {
        var cacheKey = $"content_{contentRef.ID}";

        return _cache.GetOrCreate(cacheKey, entry =>
        {
            entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10);
            return _contentLoader.Get<T>(contentRef);
        });
    }
}

C. Optimize Database Queries

// BAD: Multiple queries
var page = _contentLoader.Get<PageData>(reference);
var children = _contentLoader.GetChildren<PageData>(page.ContentLink).ToList();
var categories = children.Select(c => c.Category).ToList();

// GOOD: Single query with projection
var pageWithChildren = _contentLoader.GetChildren<PageData>(reference)
    .Select(p => new { p.Name, p.Category })
    .ToList();

D. Use Redis for Distributed Caching

// Startup.cs (CMS 12+)
public void ConfigureServices(IServiceCollection services)
{
    services.AddStackExchangeRedisCache(options =>
    {
        options.Configuration = Configuration.GetConnectionString("Redis");
        options.InstanceName = "EpiserverCache_";
    });
}

4. Content Areas Loading Slowly

Problem: Content areas with many blocks slow down rendering

Solutions:

A. Lazy Load Below-Fold Content Areas

@model ContentArea

<div class="content-area" data-lazy-load>
    @Html.PropertyFor(m => m.MainContentArea)
</div>

<script>
  // Lazy load content area
  document.addEventListener('DOMContentLoaded', function() {
    var lazyAreas = document.querySelectorAll('[data-lazy-load]');

    var observer = new IntersectionObserver(function(entries) {
      entries.forEach(function(entry) {
        if (entry.isIntersecting) {
          // Load content area content via AJAX if needed
          loadContentArea(entry.target);
          observer.unobserve(entry.target);
        }
      });
    });

    lazyAreas.forEach(function(area) {
      observer.observe(area);
    });
  });
</script>

B. Limit Content Area Items

public class LimitedContentAreaRenderer : ContentAreaRenderer
{
    protected override void RenderContentAreaItems(
        HtmlHelper htmlHelper,
        IEnumerable<ContentAreaItem> contentAreaItems)
    {
        // Only render first 5 items initially
        var items = contentAreaItems.Take(5);
        base.RenderContentAreaItems(htmlHelper, items);
    }
}

5. Custom Fonts Blocking Render

Problem: Web fonts delay text rendering

Solutions:

A. Use font-display: swap

@font-face {
  font-family: 'CustomFont';
  src: url('/fonts/custom-font.woff2') format('woff2');
  font-display: swap; /* Show fallback font immediately */
}

B. Preload Critical Fonts

<head>
    <link rel="preload"
          href="/fonts/custom-font.woff2"
          as="font"
          type="font/woff2"
          crossorigin />
</head>

C. Use System Fonts

body {
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
}

6. Third-Party Scripts Delaying Render

Problem: Tracking scripts, ads, or widgets block rendering

Solutions:

A. Load Third-Party Scripts Asynchronously

<!-- BAD: Blocking -->
<script src="https://external-service.com/widget.js"></script>

<!-- GOOD: Async -->
<script src="https://external-service.com/widget.js" async></script>

<!-- BETTER: Defer -->
<script src="https://external-service.com/widget.js" defer></script>

B. Lazy Load Third-Party Embeds

<div class="youtube-embed" data-video-id="VIDEO_ID">
    <img src="https://img.youtube.com/vi/VIDEO_ID/maxresdefault.jpg"
         alt="Video thumbnail" />
</div>

<script>
  function loadYouTubeVideo(thumbnail) {
    var videoId = thumbnail.parentElement.dataset.videoId;
    var iframe = document.createElement('iframe');
    iframe.src = 'https://www.youtube.com/embed/' + videoId + '?autoplay=1';
    iframe.allow = 'autoplay';
    thumbnail.parentElement.innerHTML = '';
    thumbnail.parentElement.appendChild(iframe);
  }
</script>

C. Use GTM to Control Third-Party Loading

Load scripts via GTM with delayed trigger:

  1. Create trigger: "Window Loaded" or "DOM Ready"
  2. Add 2-second delay
  3. Fire third-party scripts

7. Visitor Group Personalization Delays

Problem: Personalization checks slow down page load

Solutions:

A. Cache Visitor Group Results

public class CachedVisitorGroupService
{
    private readonly IVisitorGroupRepository _repository;
    private readonly IMemoryCache _cache;

    public CachedVisitorGroupService(
        IVisitorGroupRepository repository,
        IMemoryCache cache)
    {
        _repository = repository;
        _cache = cache;
    }

    public bool IsMatch(Guid visitorGroupId, IPrincipal principal)
    {
        var cacheKey = $"vg_{visitorGroupId}_{principal.Identity.Name}";

        return _cache.GetOrCreate(cacheKey, entry =>
        {
            entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5);
            var group = _repository.Load(visitorGroupId);
            return group?.IsMatch(principal) ?? false;
        });
    }
}

B. Move Personalization Client-Side

<!-- Render all variants hidden -->
<div class="personalized-content">
    <div class="variant variant-a" data-visitor-group="returning-visitors">
        @Html.PropertyFor(m => m.ContentVariantA)
    </div>
    <div class="variant variant-b" data-visitor-group="new-visitors">
        @Html.PropertyFor(m => m.ContentVariantB)
    </div>
</div>

<script>
  // Show appropriate variant client-side
  (function() {
    var isReturningVisitor = document.cookie.indexOf('returning=1') !== -1;
    var visitorGroup = isReturningVisitor ? 'returning-visitors' : 'new-visitors';

    document.querySelectorAll('.variant').forEach(function(el) {
      if (el.dataset.visitorGroup === visitorGroup) {
        el.style.display = 'block';
      } else {
        el.remove(); // Remove unused variant
      }
    });
  })();
</script>

Episerver-Specific Optimizations

1. Use Content Delivery Network (CDN)

Configure CDN for Static Assets

// Startup.cs
public void ConfigureServices(IServiceCollection services)
{
    services.Configure<StaticFileOptions>(options =>
    {
        options.OnPrepareResponse = ctx =>
        {
            ctx.Context.Response.Headers.Append("Cache-Control", "public,max-age=31536000");
            ctx.Context.Response.Headers.Append("CDN-Cache-Control", "public,max-age=31536000");
        };
    });
}

Use CDN URL for Media

public class CdnUrlResolver : IUrlResolver
{
    private readonly string _cdnUrl = "https://cdn.example.com";

    public string GetUrl(ContentReference contentLink, string language, UrlResolverArguments args)
    {
        var url = // ... get original URL

        if (IsMediaContent(contentLink))
        {
            return url.Replace("https://www.example.com", _cdnUrl);
        }

        return url;
    }
}

2. Enable Response Compression

// Startup.cs (CMS 12+)
public void ConfigureServices(IServiceCollection services)
{
    services.AddResponseCompression(options =>
    {
        options.EnableForHttps = true;
        options.Providers.Add<GzipCompressionProvider>();
        options.Providers.Add<BrotliCompressionProvider>();
    });
}

public void Configure(IApplicationBuilder app)
{
    app.UseResponseCompression();
}

For CMS 11 (web.config):

<system.webServer>
  <urlCompression doStaticCompression="true" doDynamicCompression="true" />
</system.webServer>

3. Optimize Episerver Find Queries

// BAD: Fetches all data
var results = SearchClient.Instance
    .Search<ArticlePageType>()
    .Filter(x => x.Category.Match("news"))
    .GetResult();

// GOOD: Select only needed fields
var results = SearchClient.Instance
    .Search<ArticlePageType>()
    .Filter(x => x.Category.Match("news"))
    .Select(x => new { x.Name, x.Heading, x.PublishDate })
    .Take(10)
    .GetResult();

4. Implement HTTP/2 Server Push

public class Http2PushFilter : ActionFilterAttribute
{
    public override void OnResultExecuting(ResultExecutingContext filterContext)
    {
        var response = filterContext.HttpContext.Response;

        // Push critical resources
        response.Headers.Add("Link", "</Styles/critical.css>; rel=preload; as=style");
        response.Headers.Add("Link", "</Scripts/app.js>; rel=preload; as=script");
    }
}

Testing Your LCP Improvements

Before and After Comparison

  1. Record Baseline

    • Run Lighthouse 3 times
    • Record average LCP
    • Note LCP element
  2. Implement Changes

    • Apply optimizations
    • Deploy to staging
  3. Measure Improvements

    • Run Lighthouse 3 times on staging
    • Compare to baseline
    • Calculate improvement percentage

Continuous Monitoring

// Real User Monitoring
(function() {
  new PerformanceObserver((list) => {
    const entries = list.getEntries();
    const lastEntry = entries[entries.length - 1];
    const lcpValue = lastEntry.renderTime || lastEntry.loadTime;

    // Send to analytics
    if (typeof gtag !== 'undefined') {
      gtag('event', 'lcp', {
        value: Math.round(lcpValue),
        event_category: 'Web Vitals',
        event_label: lastEntry.element?.tagName || 'unknown',
        non_interaction: true
      });
    }

    // Log for debugging
    console.log('LCP:', {
      value: lcpValue,
      element: lastEntry.element,
      url: lastEntry.url
    });
  }).observe({entryTypes: ['largest-contentful-paint']});
})();

Common LCP Pitfalls

  1. Over-optimizing: Don't sacrifice user experience for metrics
  2. Ignoring Mobile: Test on mobile devices and connections
  3. Edit Mode Testing: Always test in view mode, not edit mode
  4. Cache Invalidation: Clear caches when testing
  5. Single Page Testing: Test multiple page types
  6. Network Conditions: Test on 3G/4G, not just WiFi

Checklist

  • Optimize and preload LCP image
  • Defer non-critical JavaScript
  • Inline critical CSS
  • Enable output caching
  • Use CDN for static assets
  • Enable compression (gzip/brotli)
  • Optimize database queries
  • Lazy load below-fold content
  • Use font-display: swap
  • Load third-party scripts asynchronously
  • Test on mobile devices
  • Monitor real-user LCP metrics

Next Steps

Additional Resources