Bring the .NET 10 Blazor loading and caching behavior to .NET 9 Blazor Web Apps with one NuGet package: WebAssembly files served from the browser HTTP cache, server-side preload hints with the exact fingerprinted URLs, a compressed and fingerprinted blazor.web.js, configurable Cache-Control rules and pre-compressed static files.
Sample app in this repo (/counter, Interactive WebAssembly), Release publish, headless Chrome over HTTP/2, 150 ms latency, 4 MB/s, median of 5 runs. "Interactive" is the time from navigation until the counter button responds.
| first visit | repeat visit | repeat-visit DOMContentLoaded | |
|---|---|---|---|
| .NET 9 default | 1904 ms | 625 ms | 328 ms |
| with BetterCache | 1385 ms (−27%) | 545 ms (−13%) | 169 ms (−48%) |
The trade-off: on localhost with no latency at all, first visits are equal (598 vs 597 ms) but a repeat visit is about 90 ms slower (322 vs 416 ms), because .NET 9's Cache Storage keeps decoded copies while the HTTP cache decompresses Brotli from disk. .NET 10 made the same trade when it dropped Cache Storage. To keep Cache Storage, see Profiles.
Each point was verified in a browser on .NET 9.0.20:
- WebAssembly files through the HTTP cache. The .NET 9 runtime fetches every boot resource with
cache: "no-cache", so even immutable fingerprinted files are revalidated, and it keeps a second, uncompressed copy in Cache Storage. BetterCache sets the runtime'sdisableNoCacheFetch(through a Blazor JS initializer) so those files load from disk with no request; withBlazorCacheBootResources=falsein the client project the Cache Storage copy goes away, as in .NET 10. - Preload hints (
<BetterCacheHead />, the .NET 10ResourcePreloaderequivalent). Without them, the runtime downloads start only afterblazor.web.js→dotnet.js→blazor.boot.jsonhave loaded one after another. The server reads the boot manifest once and emitsmodulepreload/preloadlinks with the real fingerprinted URLs, so the downloads start while the head is parsed. The browser reuses every preload: no double download, no console warning. blazor.web.jscompressed and fingerprinted (<BlazorWebScript />). .NET 9 serves it uncompressed (188 KB) and revalidates it on every visit. BetterCache serves the framework's own file Brotli-compressed (45 KB) under an immutable fingerprinted URL.- Cache-Control rules. First match wins: your rules, then rules registered in DI, then the defaults (fingerprinted file names →
public, max-age=31536000, immutable; any other_frameworkfile →no-cache). By default a rule never replaces a header the endpoint already set, soMapStaticAssetskeeps its own headers; error responses are never given a policy. - Pre-compressed static files for requests no endpoint handles (for example with
UseStaticFiles): servesapp.js.br/app.js.gzwith RFC 9110Accept-Encodingnegotiation (q-values,q=0,*,identity), throughStaticFileMiddleware, so ETag, 304, HEAD and Range work.MapStaticAssetsalready negotiates compression, so its endpoints are left alone.
What it no longer does: the .NET 9 SDK already compresses every static web asset at publish (Brotli at maximum quality, measured identical to quality 11), so the old CompressAssets MSBuild task was removed.
dotnet add package BetterCache.ExtensionsAlso published to GitHub Packages:
dotnet nuget add source --username <your-github-user> --password <YOUR_GITHUB_PAT> --store-password-in-clear-text --name github "https://nuget.pkg.github.com/massimopaganigh/index.json"
dotnet add package BetterCache.Extensions --source githubProgram.cs of the server project:
using BetterCache;
builder.Services.AddBetterCache();
var app = builder.Build();
app.UseBetterCache(); // before MapStaticAssets / UseStaticFiles
app.MapStaticAssets();App.razor:
@using BetterCache
<head>
<base href="/" />
<BetterCacheHead /> @* after <base href> *@
...
</head>
<body>
<Routes />
<BlazorWebScript /> @* replaces <script src="_framework/blazor.web.js"></script> *@
</body><BlazorWebScript /> passes extra attributes through, for example <BlazorWebScript autostart="false" />.
The client (.Client) project:
<PropertyGroup>
<BlazorCacheBootResources>false</BlazorCacheBootResources>
</PropertyGroup>Without that property the runtime keeps reading its Cache Storage copy; BetterCache then skips the WebAssembly preloads, which would be wasted, and logs a warning at the first request.
| Goal | Client project | BetterCache options |
|---|---|---|
| .NET 10 behavior (default, best on real networks) | BlazorCacheBootResources=false |
defaults |
| Keep .NET 9 Cache Storage (fastest repeat visits on very fast links) | nothing | "Preload": { "PreloadWebAssembly": false }, "WebAssembly": { "UseHttpCache": false } |
The second profile still gets the compressed, fingerprinted blazor.web.js, the Cache-Control rules and the pre-compressed files.
Options bind from the BetterCache section; code passed to AddBetterCache(options => ...) runs after it. Invalid values (for example a bad regex) fail at startup.
builder.Services.AddBetterCache(options =>
{
options.CacheControl.Rules.Add(new CacheControlRule("^/downloads/", "no-store"));
options.PrecompressedFiles.FileProvider = new PhysicalFileProvider("/srv/static");
});Every service is registered with TryAdd, so you extend or replace each piece on its own:
| Interface | Default | Extend or replace |
|---|---|---|
ICacheControlRule |
CacheControlRule (regex), FingerprintedFileRule |
services.AddSingleton<ICacheControlRule, MyRule>() adds a rule after the configured ones. |
ICacheControlPolicy |
first match over the rules | Register your own before AddBetterCache() to take full control. |
IContentEncodingNegotiator |
RFC 9110 Accept-Encoding |
Register your own before AddBetterCache(). |
IPreloadLinkSource |
initializer, WebAssembly boot manifest, Preload:Links |
services.AddSingleton<IPreloadLinkSource, MySource>(); links are de-duplicated by Href. |
A rule receives the HttpContext when the response starts, so it can look at the status, Content-Type or anything else:
sealed class HtmlNoStoreRule : ICacheControlRule
{
public string? Evaluate(HttpContext context, PathString path) =>
context.Response.ContentType?.StartsWith("text/html") == true ? "no-store" : null;
}- In Development,
MapStaticAssetssendsno-cacheand ignoresIf-None-Match(for hot reload), so with the HTTP cache every reload downloads the WebAssembly files again from localhost. Published apps get304/ immutable caching. - Preload links are rendered on every page that includes
<BetterCacheHead />. If only a few pages are interactive, place it conditionally or setPreload:PreloadWebAssemblytofalse. - The boot manifest is read once per process; restart the server after rebuilding only the client.
- .NET 9 calls a web JS initializer's
beforeWebAssemblyStartonly whenBlazor.startreceives explicit WebAssembly options, so the initializer usesbeforeWebStartand thedotnet.jsbuilder instead.
<BetterCachePreload />→<BetterCacheHead />, and<script src="_framework/blazor.web.js">→<BlazorWebScript />.BetterCacheOptionsis now grouped:ImmutableMaxAgeSeconds/FrameworkPathSegment/BootManifestFileName→CacheControl.Rules;ServePrecompressedAssets→PrecompressedFiles.Enabled;EnablePreloadHints/PreloadAssets/PreloadFrameworkFromBootManifest→Preload;BootManifestPath→WebAssembly.BootManifestPath.- The MSBuild properties
BetterCacheCompress*andBetterCacheDisableBootCacheare gone. The oldBlazorCacheBootResourcesdefault never reached the client project; set it there yourself. - The old middleware marked every
/_framework/file immutable, including non-fingerprinted ones such asdotnet.jsandblazor.web.js; now only fingerprinted names are.
src/BetterCache.Extensions(net9.0): the package.tests/BetterCache.Extensions.Tests: xUnit v3 tests on TestServer.samples/BetterCache.Sample+samples/BetterCache.Sample.Client: Blazor Web App used for the measurements.
Tagged pushes (v*) run .github/workflows/publish.yml: tests, pack, then push to nuget.org (secrets.NUGET_API_KEY) and GitHub Packages (secrets.GITHUB_TOKEN).
dotnet test
dotnet pack src/BetterCache.Extensions/BetterCache.Extensions.csproj -c Release -o ./artifacts
dotnet nuget push "./artifacts/*.nupkg" --source https://api.nuget.org/v3/index.json --api-key <NUGET_API_KEY> --skip-duplicateMIT, see LICENSE.
{ "BetterCache": { "CacheControl": { "Enabled": true, "OverrideExisting": false, // replace headers set by endpoints such as MapStaticAssets "UseDefaultRules": true, "Rules": [ // case-insensitive regex on the request path, evaluated with RegexOptions.NonBacktracking { "PathPattern": "^/images/", "Value": "public, max-age=86400" } ] }, "BlazorScript": { "Enabled": true }, "Preload": { "Enabled": true, "PreloadWebAssembly": true, // boot manifest sections; default: jsModuleRuntime, jsModuleNative, wasmNative, coreAssembly, assembly "WebAssemblySections": [], "Links": [ { "Href": "fonts/inter.woff2", "As": "font", "CrossOrigin": "anonymous" } ] }, "PrecompressedFiles": { "Enabled": true, // server preference order; default: br (.br), gzip (.gz) "Encodings": [ { "Name": "zstd", "FileExtension": ".zst" }, { "Name": "br", "FileExtension": ".br" } ] }, "WebAssembly": { "UseHttpCache": true, "BootManifestPath": "_framework/blazor.boot.json" // change with a custom WebAssembly path prefix } } }