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

ASP.NET Core image optimization in C#

By Otto van der Schaaf

aspnet-core image-optimization

The standard ASP.NET Core image story is: build-time transcode with ImageSharp, hand-write the <picture> tags, hope your build pipeline keeps up with new content. The WeAmp.PageSpeed.AspNetCore middleware takes a different approach: runtime content negotiation against the client’s Accept header, with the transcoded variants generated asynchronously by a background service and cached in a memory-mapped Cyclone volume.

If you’ve read ModPageSpeed 2.0 now works with ASP.NET Core, that piece covered the architecture. This one is the image story specifically: how <img src="hero.jpg"> becomes WebP for Chrome, AVIF for modern Safari, and a viewport-sized variant for the user’s actual screen, without changing a single Razor view.

Two lines

using WeAmp.PageSpeed.AspNetCore;

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddPageSpeed();

var app = builder.Build();
app.UsePageSpeed();        // before UseStaticFiles / UseResponseCompression
app.MapRazorPages();
app.Run();

AddPageSpeed() registers the cache, the HTML processor, and a background notification channel. UsePageSpeed() inserts the middleware in the request pipeline. The order matters: PageSpeed needs to see responses before UseResponseCompression recompresses them, and before UseStaticFiles short-circuits the pipeline for static asset requests.

Install is a single package. Native binaries for linux-x64, linux-arm64, osx-arm64, and win-x64 come in transitively via the matching WeAmp.PageSpeed.NativeAssets.* package:

$ dotnet add package WeAmp.PageSpeed.AspNetCore

That’s it. No appsettings.json changes are required for image optimization; the defaults work. For the full optimization pipeline (image transcoding included), run the worker as a sidecar. See the Getting Started guide for the Docker Compose file.

What the middleware does to a request

Take a vanilla <img src="/img/hero.jpg">. Client is Chrome 120, viewport 1280px wide, Accept: image/avif, image/webp, image/apng, image/*, */*.

Request flow:

  1. Razor renders the page. PageSpeed buffers the HTML response (the middleware sits before UseResponseCompression).
  2. The HTML processor scans for <img> tags. For each one, it rewrites the src to a versioned URL like /img/hero.jpg.pagespeed.ic.7a3f9e2c.webp and adds explicit width and height attributes if it can determine them from the cache.
  3. On the next request for that rewritten URL, the cache lookup returns the pre-generated WebP variant. Zero application code involved; the middleware serves the bytes directly with the correct Content-Type and Vary: Accept headers.
  4. If the variant doesn’t exist yet (first request after deploy), the middleware returns the original JPEG and asynchronously notifies the worker to generate WebP, AVIF, and viewport-sized variants. The next request gets the optimized version.

Behind the scenes, the worker generates a set of cache variants per source image, indexed by a 32-bit capability mask combining Accept-Encoding, Accept MIME types, Save-Data, viewport class (mobile/tablet/desktop), and pixel density. The mask is computed in C++ during request classification; the actual selection is a hash lookup in the mmap’d cache.

Verify

$ curl -sI -H "Accept: image/avif,image/webp,*/*" \
    http://localhost:5000/img/hero.jpg
HTTP/1.1 200 OK
Content-Type: image/avif
Vary: Accept, Save-Data
X-PageSpeed: HIT
Cache-Control: public, max-age=31536000, immutable

Same URL with a different Accept:

$ curl -sI -H "Accept: image/jpeg" \
    http://localhost:5000/img/hero.jpg
HTTP/1.1 200 OK
Content-Type: image/jpeg
Vary: Accept, Save-Data
X-PageSpeed: HIT

Same source file, two different content types, picked at serve time from the request headers. The browser doesn’t need a <picture> tag with <source type="image/webp"> fallbacks; the negotiation happens at the URL level, transparent to markup.

Save-Data and viewport

Two additional axes the middleware honors automatically:

$ curl -sI -H "Accept: image/avif" -H "Save-Data: on" \
    http://localhost:5000/img/hero.jpg
HTTP/1.1 200 OK
Content-Type: image/avif
Content-Length: 18432    # ~40% smaller than the no-Save-Data variant
Vary: Accept, Save-Data
X-PageSpeed: HIT

When the browser sends Save-Data: on (Chrome on Lite Mode, or any client on a metered connection), the background service has pre-generated lower-quality variants. The selection is automatic.

For viewport-aware sizing, the middleware reads the Sec-CH-Viewport-Width client hint (where supported) or falls back to a User-Agent classification. A 1920×1080 hero image served to a phone gets the 412px-wide variant; the desktop gets the full size. Neither client downloads the other’s bytes.

LCP preload

In addition to image variants, the middleware adds preload hints for the largest contentful paint candidate. The HTML processor identifies the likely LCP image (first large <img> in the document, or one inside a hero/banner-classed container) and emits a Link response header:

Link: </img/hero.jpg.pagespeed.ic.7a3f9e2c.webp>; rel=preload; as=image

This is a real response header on the HTML document; the browser starts fetching the LCP image during the document parse, before it reaches the <img> tag. Typical LCP improvement on hero-heavy landing pages is 200 to 600 ms on a cold 4G connection.

The nginx integration also emits 103 Early Hints for this on cache misses, sending preload headers before the origin response arrives. Kestrel does not currently expose that capability to middleware, so the ASP.NET path emits the hints as regular response headers only. See the architecture post for the nginx-vs-middleware comparison table.

vs ImageSharp.Web

ImageSharp.Web is the standard answer for ASP.NET Core image processing. It’s a great library; the comparison is about where the optimization happens.

ImageSharp.WebWeAmp.PageSpeed
LanguageC#C++ via P/Invoke
AVIF encodingNot built-in (planned)Yes (libaom on Linux/macOS)
WebP encodingYesYes
Content negotiationURL query string (?format=webp)Accept header transparent
Markup changes<img src="?format=webp">None (the <img> tag is rewritten by the HTML pass)
Viewport variantsManual (?width=412)Automatic
LCP preloadNoYes
Critical CSSNoYes (out of scope for this post)
LicenseApache 2.0Commercial subscription
PricingFree14-day trial, card-at-start

ImageSharp.Web is the right choice if you want a focused, free, in-process image library and you’re happy to drive variants from query strings.

WeAmp.PageSpeed is the right choice if you want runtime content negotiation against Accept, AVIF today, and the HTML-level optimization passes (critical CSS, LCP preload, lazy-load injection) that the same middleware provides as a side effect of being a PageSpeed pipeline rather than an image library.

(Footnote: the two can coexist. ImageSharp.Web for build-time generation and WeAmp.PageSpeed for runtime negotiation. The middleware doesn’t care whether the bytes it sees on disk came from a build step or a CDN.)

Limitations

  • Preview status. WeAmp.PageSpeed.AspNetCore is pre-release. API surface may change between versions.
  • AVIF on Windows. The win-x64 native build ships without libaom. WebP works; AVIF transcoding is silently skipped on Windows. Linux and macOS have full AVIF support.
  • Trial requires a card. The trial is card-at-start via FastSpring, same as the nginx path. License tokens are activated via the workbench at my.we-amp.com.
  • HTML processing is async. The first request for a new page gets the unprocessed HTML; the second request gets the rewritten version. For pre-generated static content, hit the URLs once during deploy to warm the cache.

Configuration reference

Everything is hot-reloadable via IOptionsMonitor<PageSpeedOptions>:

{
  "PageSpeed": {
    "Enabled": true,
    "LicenseKey": null,
    "Cache": {
      "VolumePath": "/var/cache/pagespeed/volume.dat",
      "VolumeSizeBytes": 1073741824
    },
    "Worker": {
      "AutoStart": true,
      "ApiPort": 9880
    }
  }
}

For the full options reference and how the cache volume sizing maps to expected variants, see ASP.NET Core Configuration or the NuGet package README.

Read next