Skip to main content

ASP.NET Core on IIS: the WeAmp.PageSpeed.AspNetCore shape

Updated 2026-10-22 · We-Amp B.V. · about an 8-minute read

The most common .NET enterprise topology we see is ASP.NET Core deployed in-process behind IIS, fronted by the ASP.NET Core Module (ANCM). Customers ask us a recurring question: where does image and CSS optimization fit when the request flows through IIS, then ANCM, then Kestrel, then back out? Architectural map first, then the practical Program.cs.

The request path, drawn out

ANCM is a native IIS module that forwards incoming requests into an in-process Kestrel instance hosted inside the IIS worker process (w3wp.exe). The path looks like:

browser
  -> IIS HTTP.SYS
  -> w3wp.exe (IIS worker)
  -> ASP.NET Core Module (in-process)
  -> Kestrel
  -> ASP.NET Core middleware pipeline
  -> your application

Two things follow from that path. First, the IIS-level handlers (static-file handler, dynamic compression, URL rewrite) only see requests that hit IIS-managed paths, not requests that ANCM forwards to Kestrel. Second, ASP.NET Core's UseStaticFiles() middleware serves the static assets, which means any request-time optimization has to happen inside the ASP.NET Core pipeline, not at the IIS layer.

That is why the IISpeed-style native IIS module is the wrong layer for an ASP.NET Core application. The mod_pagespeed IIS module plugs into the IIS request pipeline at a level that ANCM has already bypassed for ASP.NET Core requests. The module is the right tool for classic ASP.NET, WebForms, and SharePoint, but the wrong tool for an application whose every request gets handed off to Kestrel.

Where WeAmp.PageSpeed.AspNetCore fits

WeAmp.PageSpeed.AspNetCore is the ASP.NET Core form of mod_pagespeed — the same PageSpeed Automatic optimization library as the IIS module, repackaged for the IApplicationBuilder idiom and shipped as a NuGet package. The middleware sits inside the Kestrel pipeline, so it sees every request whether you host behind IIS via ANCM, run Kestrel directly behind a reverse proxy, or run a container on a different OS entirely.

Registration in Program.cs

Add the package, then register it:

dotnet add package WeAmp.PageSpeed.AspNetCore
using WeAmp.PageSpeed.AspNetCore;

var builder = WebApplication.CreateBuilder(args);

// Registers the services and binds the "PageSpeed" section of
// appsettings.json. Enabled, Html and the other top-level keys
// hot-reload; the Cache and Worker sections are read once at startup.
builder.Services.AddPageSpeed();
builder.Services.AddRazorPages();

var app = builder.Build();

// Order matters: UsePageSpeed before anything that writes the response
// body — before UseStaticFiles so it sees the static-asset responses,
// before the endpoints so it can rewrite the application's HTML.
app.UsePageSpeed();
app.UseStaticFiles();
app.MapRazorPages();
app.Run();

The middleware reads each response, decides whether to optimize (HTML, CSS, JavaScript, images), and rewrites the body in place. Bytes that should pass through (application/json API responses, and anything under a prefix listed in ExcludePaths — /api/, /signalr/, /_blazor/ and /_framework/ by default) skip the optimization layer.

UsePageSpeed() also mounts the middleware's /console/ admin SPA and its worker-API proxy on the public site, on by default and with no credential of its own. Treat it as an admin surface: gate the path at the IIS layer (and set Console.RequireHttps so it is never served in the clear), move it off a guessable path with Console.MountPath, or switch it off with Console.Enabled: false — the package README's Security section has the detail.

The options live in the PageSpeed section of appsettings.json (or any other IConfiguration source), which AddPageSpeed() binds. Every key is optional and falls back to its default — the five Html toggles below are spelled out at their defaults, so you can see what there is to flip; the configuration reference lists them all.

{
  "PageSpeed": {
    "Enabled": true,
    "Cache": {
      "VolumePath": "C:\\ProgramData\\WeAmp.PageSpeed\\cache\\volume.dat"
    },
    "Html": {
      "EnableCriticalCss": true,
      "EnableLazyLoad": true,
      "EnableImageDimensions": true,
      "EnableLcpPreload": true,
      "EnableScriptDeferral": true
    }
  }
}

Then dotnet run and curl -i a content route: the response carries X-PageSpeed: MISS on the first request and HIT once the worker has built the variant — the verify steps on modpagespeed.com cover the rest.

The switches worth knowing about

There is no rewrite level and no filter list: the image pipeline is on out of the box, and each HTML rewrite is a boolean under PageSpeed:Html. The ones that do most of the Core Web Vitals work:

  • Image transcoding — not a toggle. The middleware serves WebP or AVIF at the original URL to browsers that advertise the format in the request's Accept header, with viewport-aware resizing. Hero-image weight typically drops by an order of magnitude on image-heavy Razor pages.
  • EnableImageDimensions and EnableLazyLoad: explicit width and height on images that omit them (Cumulative Layout Shift) and loading="lazy" on below-the-fold images; EnableLcpPreload adds the preload hint for the detected LCP image.
  • EnableCriticalCss: extracts the above-the-fold CSS rules and inlines them in the document head. Set EnableAsyncCss: true as well (off by default) to defer the remaining stylesheets through the middleware's async loader — that pair is what takes the stylesheet round trip off first paint.
  • EnableScriptDeferral: defers scripts based on browser coverage analysis, moving non-critical JavaScript out of the critical path. Interaction to Next Paint improves on heavy Razor pages with multiple jQuery plugins or vendored widgets.

Interaction with IIS-level features

Hosting ASP.NET Core behind IIS means a handful of IIS-level features are still in the path even though they do not see the application bytes:

  • IIS Static Content Compression. Leave it on. The middleware emits CSS and JavaScript that compresses well, and the IIS compression layer applies gzip / Brotli to the response before it leaves w3wp.exe. The two features stack rather than overlap.
  • HTTP/2. IIS terminates HTTP/2 at HTTP.SYS and downgrades the in-process forward to HTTP/1.1, so Kestrel receives an HTTP/1.1 request from ANCM regardless of what the client spoke. The middleware's behaviour is the same on HTTP/1.1 and HTTP/2; no change in configuration. The LCP-preload and preconnect hints go out as Link: rel=preload / rel=preconnect response headers, which IIS forwards untouched; nothing is pushed.
  • URL Rewrite Module. Any URL rewrite rules in web.config execute at the IIS layer before ANCM hands off to Kestrel; they affect what URL the application sees but do not interact with the optimization layer.
  • Application-pool identity and the cache volume. The middleware keeps optimized variants in a single cache volume file, PageSpeed:Cache:VolumePath. Its default, /var/cache/pagespeed/volume.dat, is a Linux path, so on Windows set it explicitly — the appsettings.json above uses C:\ProgramData\WeAmp.PageSpeed\cache\volume.dat. Make it absolute: under in-process hosting a relative path resolves against w3wp.exe's working directory, not the application's. The middleware creates the folder on first start if it is missing, but it does so as the application-pool identity (commonly IIS APPPOOL\<site>), so grant that identity Modify on the parent folder beforehand — nothing in the package sets ACLs for you.

When to skip the middleware

Three cases where adding the middleware does not buy you anything:

  • Blazor Server. Blazor Server renders the bulk of the page over a persistent SignalR connection, not as HTTP responses the middleware can rewrite. The image and CSS optimization still applies to the initial document and any static assets, but Interaction to Next Paint on a Blazor Server application is dominated by SignalR round-trip latency, which the middleware cannot touch. Architectural improvements (Blazor WebAssembly, Blazor Auto, or moving expensive interactions client-side) are the right tool there.
  • Pure JSON APIs. If the application only emits application/json from controller endpoints, there is nothing for the middleware to rewrite. The optimizations apply to HTML, CSS, JavaScript, and image responses. Run it for the static-asset side only if the API has a companion documentation site.
  • You already run a CDN with image-optimization features turned on. Two image optimizers in sequence usually overlap without benefit. Pick one layer (either the CDN or the middleware) and run the other in pass-through.

Pure-Kestrel deployment

The same registration works unchanged when you take IIS out of the picture entirely: Kestrel directly on a Linux container, fronted by nginx as a reverse proxy, in Docker on a developer workstation. That portability is part of the reason ASP.NET Core teams pick middleware over the IIS-module path: the same optimization runs in every environment the application runs in, including the local dotnet run loop.

See the ASP.NET Core middleware deep-dive on modpagespeed.com for the longer write-up: performance characteristics, the P/Invoke layer that bridges the C# pipeline to the native PageSpeed Automatic library, and the configuration shape under appsettings.json.

Where to start

The WeAmp.PageSpeed.AspNetCore NuGet package is the starting point: install it and run it — the line is open source under Apache-2.0, free in development and in production, on every platform you deploy to. What We-Amp sells is support. Existing IISpeed customers moving to ASP.NET Core transfer their license at no cost through the license-transfer form.

Add WeAmp.PageSpeed.AspNetCore to an ASP.NET Core app

Add two lines to Program.cs and run it — the line is open source under Apache-2.0, free in development and in production; support plans are available.

Related