ASP.NET Core CLS: pre-size async holes
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 middleware — insert_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
WidthandHeightto the view model on upload. Read them once withSixLabors.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.cshtmlor 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.CookiePolicycan 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>withmin-heightmatching 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
- 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.
- DevTools → Performance → record a reload trace with 4× CPU throttle. The Layout Shift Regions overlay should be empty across the page.
- After 28 days, Search Console → Core Web Vitals report. The URL group should move from “Needs improvement” to “Good”.
- 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-heightthat 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 afterinsert_image_dimensions, the middleware is not running on the response (checkapp.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-heightproblem 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: nonefrom the start of the page lifetime; no JS-driven visibility change.
Related
- How to fix LCP on ASP.NET Core
- How to fix INP on ASP.NET Core
- How to fix CLS on nginx
- ModPageSpeed 2.0 as ASP.NET Core middleware
- IIS Core Web Vitals 2026 (iispeed.com)
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
-
nginx CLS via insert_image_dimensions
How to fix CLS on nginx: rewrite img tags to add width/height, reserve container space, use aspect-ratio, ship dimensions at build time.
-
Magento CLS starts with Fotorama
How to fix CLS on Magento: tame Fotorama gallery jumps, pre-size mini-cart and private-content blocks, rewrite img dimensions at the server layer.
-
WooCommerce CLS: lock the gallery
How to fix CLS on WooCommerce: stop the gallery shift, lock variation swap dimensions, reserve cross-sell space, and rewrite img tags at the server.