Skip to main content
ModPageSpeed 2.0 and mod_pagespeed 1.1 — Now available

ASP.NET Core CLS: pre-size async holes

By Otto van der Schaaf

core-web-vitals cls aspnet-core

CLS on ASP.NET Core splits into two markup-hygiene problems: <img> tags missing dimensions (uploaded files where the view model dropped Width/Height), and async view components that mount empty boxes via @await Component.InvokeAsync(...). The first is solved at the response-stream layer by the WeAmp.PageSpeed ASP.NET Core middlewareinsert_image_dimensions reads the file once and writes the attributes for every Razor view that emits <img>. The second is solved by sizing the async holes the framework explicitly chose to defer.

What CLS measures

Cumulative Layout Shift records every unexpected movement of visible content between load start and the moment the page becomes interactive. Each shift is scored by the fraction of the viewport that moved multiplied by the distance moved. The session window with the highest sum becomes the CLS value. A “good” score per web.dev/cls is under 0.1; “poor” begins at 0.25.

Shifts inside 500 ms of a user interaction are excluded. The metric you optimize is the field 75th-percentile CLS from CrUX, shown in PSI’s “Origin” or “Page” CrUX panel. Lighthouse lab CLS is a single synthetic run and often disagrees with the field — especially for ASP.NET Core sites where async view components hydrate on a timing the lab doesn’t reproduce.

The most common CLS failures on ASP.NET Core

Three patterns cover the bulk of ASP.NET Core CLS regressions.

Async view components mount late and fill empty boxes. @await Component.InvokeAsync("RelatedProducts") returns markup that does not exist in the initial response. If the view component fetches data from a downstream API (catalog, recommendations, inventory), the wait happens on the request thread but the placeholder mounted in the parent view is empty. Renderers using IViewComponentResult with caching headers behave more predictably, but the common pattern — async component, no skeleton, no reserved height — produces a textbook CLS hit.

Razor <img> tags from IFormFile uploads lack dimensions. Admin pattern: a user uploads a JPEG via IFormFile, the controller saves it to wwwroot/uploads/ or blob storage, the view model carries string ImageUrl. The Razor view renders <img src="@Model.ImageUrl" alt="..."> with no width or height. The dimensions are known at upload time (the file’s pixel dimensions can be read in 1 ms with SixLabors.ImageSharp) but rarely persisted to the view model. Every catalog page, every news article, every uploaded-image surface suffers the same shift.

Cookie consent banners injected by middleware. Microsoft.AspNetCore.CookiePolicy and similar third-party packages inject a top-of-page banner on first visit through a IHtmlGenerator hook. The banner is in the response stream but the JavaScript that controls its visibility runs after the initial paint; the banner appears, the main content pushes down, CLS records the shift. Banners injected via JavaScript only (no server-side markup) are even worse.

Confirm it’s actually a CLS problem

Run PSI on the affected URL: https://pagespeed.web.dev/analysis?url=https://example.com/. The “Avoid large layout shifts” diagnostic lists the elements; on ASP.NET Core the worst offender is usually an async-loaded section or a <img> tag rendered from a view-model URL.

In Chrome DevTools: Performance panel → Settings cog → enable “Web Vitals” and “Layout Shift Regions”. Throttle CPU to 4×. Reload. The async view component appearing late shows as a red rectangle at the section’s location; the un-sized image shows as a red rectangle wherever the <img> sits in the document flow.

The web-vitals npm package logged from a dev-only Razor block gives you precise attribution:

@if (Env.IsDevelopment())
{
    <script type="module">
      import { onCLS } from 'https://unpkg.com/web-vitals?module';
      onCLS(console.log, { reportAllChanges: true });
    </script>
}

The console prints the worst element per session. Match it to one of the three failure modes.

Step 2: Insert img dimensions in-process via the WeAmp.PageSpeed middleware

WeAmp.PageSpeed.AspNetCore — the NuGet middleware that wraps the same optimization pipeline as the nginx integration — handles the <img>-dimensions problem in one line of startup config. The middleware sits in the response pipeline, inspects the outbound HTML stream, reads the underlying file (cached after first hit; works with wwwroot/ paths and with absolute URLs your origin serves), and inserts width and height on every <img> missing them. Combined with the bootstrap CSS rule img { max-width: 100%; height: auto; } (or Tailwind’s equivalent), the browser reserves the correct aspect-ratio box before pixels arrive. The “image jump” shift disappears across every Razor view, every Blazor component that emits <img>, every Markdown-rendered article body.

lazyload_images reserves dimensioned placeholders for offscreen images and defers the fetch. Because the placeholder is sized, no shift fires when the real image swaps in.

prioritize_critical_css inlines above-fold CSS into the document head and defers the rest. On ASP.NET Core sites that ship Bootstrap, Tailwind, or a large compiled stylesheet from _Layout.cshtml, this measurably reduces the FOUC window during which fonts and layout can shift.

Why a rewriter beats auditing every Razor file: it applies the fix uniformly, including content from CMS imports, Markdown converters, and third-party Razor packages whose templates you don’t control. Per-file auditing works for green-field projects; the middleware works for the inherited ones.

What this does not fix: the async-view-component shifts, the cookie-banner inject, and the Blazor Server hydration mount. The rewriter can only operate on bytes the response stream actually emits — it cannot pre-size content that the framework explicitly chose to defer.

What Razor can still do for you

  • For uploaded images, persist Width and Height to the view model on upload. Read them once with SixLabors.ImageSharp:
using var image = await Image.LoadAsync(file.OpenReadStream());
upload.Width = image.Width;
upload.Height = image.Height;

Then render with explicit attributes: <img src="@Model.ImageUrl" width="@Model.Width" height="@Model.Height" alt="...">. The middleware’s rewrite becomes a no-op (it only inserts when attributes are missing); the dimensions are correct from the first byte.

  • For async view components, replace the empty mount point with a pre-sized skeleton. _Layout.cshtml or the parent view:
<div class="related-products-skeleton" style="min-height: 320px;">
  @await Component.InvokeAsync("RelatedProducts")
</div>

The skeleton’s min-height matches the expected populated height; when the component completes, the slot stays the same size and no shift fires.

  • Switch cookie-banner injection to server-side rendering. Microsoft.AspNetCore.CookiePolicy can emit the banner markup inline in _Layout.cshtml; CSS controls visibility based on a cookie set by middleware. No JS-driven appearance, no shift.

  • For Blazor Server: wrap server-rendered components that may expand on hydration in a <div> with min-height matching the expected post-hydration size. Hydration happens asynchronously over SignalR; CLS records the expansion if you let the component mount into a zero-height box.

  • For static images shipped from wwwroot/, build-time tooling can also inject dimensions. The middleware approach is more general because it covers user-uploaded content; the build-time approach is faster (no per-request inspection). Both stack.

Measuring the lift

  1. Re-run PSI. “Avoid large layout shifts” should drop to zero shifted elements or list only the cookie banner if you haven’t moved it server-side.
  2. DevTools → Performance → record a reload trace with 4× CPU throttle. The Layout Shift Regions overlay should be empty across the page.
  3. After 28 days, Search Console → Core Web Vitals report. The URL group should move from “Needs improvement” to “Good”.
  4. Watch the CrUX field CLS in PSI’s “Origin” panel. The lab Lighthouse CLS sometimes reports 0 because the lab doesn’t trigger the async view-component path; the field is the truth.

The drop-in middleware config

For ASP.NET Core via the WeAmp.PageSpeed.AspNetCore NuGet package, the minimal startup snippet:

using WeAmp.PageSpeed.AspNetCore;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddPageSpeed(options =>
{
    options.Cache.VolumePath = "/data/cache.vol";
    options.Cache.VolumeSizeBytes = 256 * 1024 * 1024;
    options.Worker.SocketPath = "/data/pagespeed.sock";
    options.Filters = new[]
    {
        "insert_image_dimensions",
        "lazyload_images",
        "prioritize_critical_css",
    };
});

var app = builder.Build();
app.UsePageSpeed();

If you front Kestrel with nginx or IIS instead of running the middleware in-process, use the equivalent server-level configuration from the ASP.NET Core middleware post or the IIS integration referenced in /iis-core-web-vitals-2026/ on iispeed.com.

When this doesn’t work

  • If CLS attribution still names the async view component after the skeleton change, the component’s populated height is highly variable (e.g., a “recently viewed” list with 0–10 items). Pick a fixed min-height that matches the empty state, not the populated one — an empty box is shift-free; a sometimes-empty, sometimes-full box always shifts.
  • If <img> shifts persist after insert_image_dimensions, the middleware is not running on the response (check app.UsePageSpeed() ordering — it must run before any compression or response-caching middleware) or the images are served from a CDN the middleware cannot fetch metadata from. Persist dimensions to the view model as the durable fix.
  • Blazor WebAssembly: the rewriter sees only the initial app-shell HTML, not the components rendered client-side. CLS in Blazor WASM is a min-height problem on every component container — solve in CSS.
  • If CLS attribution names a cookie banner that you have already moved server-side, the JS that hides the banner on accept fires after the initial paint, and that hide event records as a shift. Hide the banner with a server-set cookie + CSS display: none from the start of the page lifetime; no JS-driven visibility change.

ModPageSpeed runs as an ASP.NET Core middleware (NuGet) or an nginx / Apache / IIS module. On ASP.NET Core, run your app and start the 14-day trial from the console at /console/ (card-at-start via FastSpring). See license terms.

Read next