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
- Go to Search Console → Core Web Vitals
- View LCP data for your Episerver site
- 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
- Visit PageSpeed Insights
- Enter your Episerver page URL
- Review LCP timing and suggestions
Lighthouse
# Run Lighthouse CLI
lighthouse https://your-episerver-site.com --view
Or use Chrome DevTools:
- Open DevTools (F12)
- Go to Lighthouse tab
- Run analysis
- Review LCP section
WebPageTest
- Go to WebPageTest
- Enter URL and location
- Review "Largest Contentful Paint" metric
- 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:
- Create trigger: "Window Loaded" or "DOM Ready"
- Add 2-second delay
- 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
Record Baseline
- Run Lighthouse 3 times
- Record average LCP
- Note LCP element
Implement Changes
- Apply optimizations
- Deploy to staging
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
- Over-optimizing: Don't sacrifice user experience for metrics
- Ignoring Mobile: Test on mobile devices and connections
- Edit Mode Testing: Always test in view mode, not edit mode
- Cache Invalidation: Clear caches when testing
- Single Page Testing: Test multiple page types
- 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
- CLS Optimization - Fix layout shifts
- Troubleshooting Overview - Other common issues
- Performance Monitoring - Learn about Web Vitals