Taking Blazor to Production: WASM Size, Trimming, AOT, Circuit Resources, Reconnection UI and Hosting
Taking Blazor to production means measuring two costs: the first-visit download in WebAssembly (2.3 MB for the Bookstore panel, 4.3 MB with AOT but about six times faster on CPU work) and memory per circuit on the server (about 800 KB on the stock page, with a closed tab kept for three minutes). The post covers trimming and relinking, fingerprinted caching, CircuitOptions, ReconnectModal, a nonce-based Content Security Policy and WebSockets behind Nginx, all with real measurements.
Version note: The code in this post was built and run with .NET 10 (SDK 10.0.404, ASP.NET Core 10.0.15) using the wasm-tools workload (10.0.15) for the WebAssembly build tools, dotnet-counters 10.0 for measurements and Nginx 1.30 as the reverse proxy; browser behaviour was observed with Playwright 1.65's headless Chromium.
Predict First
For eleven posts the admin panel of Bookstore, the online bookstore used as the shared example across this site's series, has run on a development machine. In this post we get it ready for production: we measure how much the WebAssembly side downloads, see what InteractiveServer circuits cost the server, and try the reconnection UI, a Content Security Policy and a reverse proxy. Three questions first. How much does AOT grow the download, and how much faster does it make CPU-heavy work? When a user closes the tab, when does the circuit leave memory? If you forget the WebSocket headers in Nginx, does the panel stop working, or does something else happen?
The WebAssembly Download Size
Size only means something for a Release publish; a Debug build has no trimming and no compressed files. I published the panel three ways: with the plain SDK, with an SDK that has the wasm-tools workload, and with AOT:
$ dotnet publish src/Bookstore.Admin -c Release -o publish/il
$ dotnet workload install wasm-tools
$ dotnet publish src/Bookstore.Admin -c Release -o publish/relink
$ dotnet publish src/Bookstore.Admin -c Release -o publish/aot -p:RunAOTCompilation=true
The _framework folder on disk:
publish files on disk .br total dotnet.native.wasm (.br)
IL 53 9.4 MB 2.8 MB 2.9 MB (954 KB)
IL + relink 53 9.3 MB 2.8 MB 2.8 MB (909 KB)
AOT 53 20.2 MB 4.7 MB 13.6 MB (3182 KB)
The roughly 9 MB folder comes to 2.8 MB with Brotli. Since .NET 8 assemblies ship in the Webcil format with a .wasm extension; publish trims IL by default, but the granularity is partial: only framework libraries and libraries that declare themselves trim-compatible are trimmed. With the workload installed, a Release publish also relinks the runtime and drops runtime code that isn't used; dotnet.native.wasm went from 954 KB to 909 KB. The gain is small because the panel uses globalization data too; if an app doesn't need culture-specific formatting, InvariantGlobalization stops that data from being sent at all.
The total on disk isn't what the user downloads; the browser only fetches the files the boot asks for. I opened a page with a WebAssembly component in a browser with an empty cache:
il: _framework 50 files, 2367 KB on the wire, WebAssembly interactive after 282 ms
Server runs 30, 19, 22, 21, 22 ms, median 22 ms
WebAssembly runs 98, 89, 92, 90, 91 ms, median 91 ms
relink: _framework 50 files, 2336 KB on the wire, WebAssembly interactive after 274 ms
Server runs 32, 21, 20, 20, 20 ms, median 20 ms
WebAssembly runs 103, 105, 100, 100, 104 ms, median 103 ms
aot: _framework 50 files, 4357 KB on the wire, WebAssembly interactive after 374 ms
Server runs 32, 20, 22, 23, 19 ms, median 22 ms
WebAssembly runs 24, 14, 15, 16, 17 ms, median 16 ms
50 files and 2.3 MB on the first visit; in post 01 we measured 2.2 MB, and the code added since has grown it a little. On the second visit these files come from the cache under their fingerprinted names. The times on the page were measured locally with no network latency; for a real user the main cost is the download time.
AOT: Size or Speed?
Without AOT, .NET code runs in the browser in an IL interpreter; the documentation calls it the "Jiterpreter", since it partly JIT-compiles hot code. AOT compiles the code to WebAssembly during publish. To measure the difference I wrote a diagnostics component that only uses the CPU: ISBN-13 check digits for two million ISBNs:
// CPU-only work: no I/O, no JS interop, so the time is the .NET code itself.
private void Run()
{
Stopwatch watch = Stopwatch.StartNew();
long checksum = 0;
for (long first12 = 978_000_000_000; first12 < 978_000_000_000 + Count; first12++)
{
checksum += CheckDigit(first12);
}
watch.Stop();
result = string.Create(CultureInfo.InvariantCulture,
$"Run {++runs}: {Count:N0} ISBN-13 check digits in {watch.ElapsedMilliseconds} ms (checksum {checksum})");
}
// Weights 1 and 3 alternate from the left; the rightmost of the first 12 digits has weight 3.
private static int CheckDigit(long first12)
{
int total = 0;
for (int i = 0; i < 12; i++)
{
total += (int)(first12 % 10) * (i % 2 == 0 ? 3 : 1);
first12 /= 10;
}
return (10 - total % 10) % 10;
}
The page has the same component once as InteractiveServer and once as InteractiveWebAssembly; in the output above each ran five times. The answer to the first question: AOT grew the first-visit download from 2.3 MB to 4.3 MB and cut the same work from 91 ms to 16 ms. The documentation says most AOT-compiled apps are about twice the size; here dotnet.native.wasm alone became 13.6 MB because the compiled code now lives there. The server's .NET JIT does the same work in about 20 ms; AOT brings WebAssembly close to server speed.
For the panel the decision is to stay without AOT: its pages show data and fill in forms, there's no CPU-heavy computation, and the first load is paid by every new staff member. An app that does image processing, filtering of large data sets or calculations in the browser may measure the other way. AOT only runs on publish and takes long; in this project publish went from 29 to 53 seconds. That's why turning it on in the pipeline with -p:RunAOTCompilation=true instead of fixing it in the csproj also keeps the build working for developers without the workload. Lazy loading is for downloading large assemblies of your own only when they're needed; it doesn't apply to framework assemblies.
Fingerprints, Compression and Caching
MapStaticAssets serves the .br and .gz files created during publish according to the browser's Accept-Encoding, and gives fingerprinted names a long-lived cache header. Two requests in the Production environment:
$ curl -sD - -o /dev/null -H "Accept-Encoding: br, gzip" http://localhost:5037/_framework/blazor.web.whefm3rrpu.js
HTTP/1.1 200 OK
Content-Length: 47542
Cache-Control: max-age=31536000, immutable
Content-Encoding: br
$ curl -sD - -o /dev/null -H "Accept-Encoding: br, gzip" http://localhost:5037/_framework/blazor.web.js
HTTP/1.1 200 OK
Content-Length: 47542
Cache-Control: no-cache
Content-Encoding: br
The page uses the fingerprinted name (@Assets["..."], ImportMap); because the name changes when the file changes, a one-year immutable cache is safe. The name without a fingerprint still works but asks for revalidation every time. In .NET 10 the boot configuration is no longer a separate blazor.boot.json file but is embedded in dotnet.js, and the ResourcePreloader component in App.razor starts loading the WebAssembly files as soon as the page opens. If you put the static files on a CDN, the same names and headers have to survive there too.
Memory per Circuit and Closed Tabs
In InteractiveServer every open tab is a circuit on the server: the render tree, the components, the services in the circuit scope and the SignalR connection. The documentation gives the memory as the number of active and disconnected circuits times the memory per circuit. I ran a Release build of the panel and opened the stock page in 20 separate browser contexts, measuring the GC heap after a full collection with an endpoint added only for this experiment:
40 s no circuits: GC heap 36904 KB
45 s 20 tabs open: GC heap 53431 KB, about 826 KB per circuit
45 s 20 tabs closed
90 s 45 s later: GC heap 35684 KB
About 800 KB per circuit for the stock page. The number depends on the page; in post 08 a single render batch of the 10,000-row list without Virtualize was 3.1 MB. A thousand concurrent staff members on this page means 800 MB. In the same run I watched the circuit counts with dotnet-counters:
$ dotnet-counters collect -p <pid> --counters Microsoft.AspNetCore.Components.Server.Circuits --refresh-interval 5 --format csv
(the CSV condensed; samples every 5 s, repeated rows left out)
time circuit.connected circuit.active
+0 s 20 20
+5 s 0 20
+30 s 0 20
+35 s 0 0
Here's the answer to the second question. When the tabs close, connected drops to zero but active stays at 20: the circuits wait as disconnected, because the user may come back after a short drop. For this run I set the wait to 30 seconds; the default is three minutes and at most 100 disconnected circuits are kept. That's why the closed tab's timer kept logging for three minutes in post 06. In the panel these values now come from configuration:
builder.Services.AddRazorComponents()
.AddInteractiveServerComponents()
.AddInteractiveWebAssemblyComponents()
.AddAuthenticationStateSerialization();
// Circuit limits come from configuration (Blazor:Circuits), so they can be tuned per environment without a build.
builder.Services.Configure<CircuitOptions>(builder.Configuration.GetSection("Blazor:Circuits"));
"Blazor": {
"Circuits": {
"DisconnectedCircuitMaxRetained": 100,
"DisconnectedCircuitRetentionPeriod": "00:03:00",
"JSInteropDefaultCallTimeout": "00:01:00"
}
},
For now the values equal the defaults, but they're visible and can change per environment. On a server with little memory, lowering the period and the count frees memory quickly; in return, a user whose connection drops briefly starts over with a new circuit. JSInteropDefaultCallTimeout is the one-minute limit on JS calls we discussed in post 10.
.NET 10 added the Microsoft.AspNetCore.Components, Microsoft.AspNetCore.Components.Lifecycle and Microsoft.AspNetCore.Components.Server.Circuits meters for Blazor, plus activities for circuit start, navigation and event handling; circuit.active and circuit.connected above come from them. One detail: dotnet-counters shows these UpDownCounters as the increments and decrements it has seen since it started listening. When I started a session while circuits were open, the values went negative; for the right numbers you need an OpenTelemetry exporter attached when the app starts. Setting that up is the subject of the Observability series.
ReconnectModal and Reconnecting
The .NET 10 template puts the reconnection UI inside the app as a component: ReconnectModal.razor with a collocated CSS and JS file. Blazor dispatches the components-reconnect-state-changed event to the dialog element as the state changes, and the template's module acts on it:
function handleReconnectStateChanged(event) {
if (event.detail.state === "show") {
reconnectModal.showModal();
} else if (event.detail.state === "hide") {
reconnectModal.close();
} else if (event.detail.state === "failed") {
document.addEventListener("visibilitychange", retryWhenDocumentBecomesVisible);
} else if (event.detail.state === "rejected") {
location.reload();
}
}
With the stock page open I stopped the server process and started it again twelve seconds later:
0.2 s inventory page open, circuit connected
1.7 s server process stopped
1.7 s components-reconnect-state-changed: {"state":"show"}
1.7 s components-reconnect-state-changed: {"state":"retrying","currentAttempt":1,"secondsToNextAttempt":0}
13.7 s dialog open: true, text: "Rejoining the server..."
13.7 s starting the server again (new process, new memory)
21.8 s ... 33 more "retrying" events, the last one: {"state":"retrying","currentAttempt":14,"secondsToNextAttempt":0}
21.8 s components-reconnect-state-changed: {"state":"rejected"}
21.9 s page load /inventory
42.9 s now on /inventory, rows: 20
First came show, then a retrying on every attempt; the dialog said "Rejoining the server...". The new process didn't know the old circuit, so rejected arrived and the module reloaded the page. The user didn't have to sign in again because the cookie was still valid, but what they had typed into forms and the page's in-memory state were gone. The circuit state persistence from post 06 is kept in memory, so it's lost when the process restarts too; to keep state across a deploy, and to continue on another server when there are several, the documentation describes writing persisted circuit state to a HybridCache backed by a distributed store such as Redis.
Content Security Policy
In post 10 we said inline event handlers can be blocked by a CSP. The panel now has one; it's on outside Development and creates a new nonce for every response:
public static IApplicationBuilder UseContentSecurityPolicy(this IApplicationBuilder app) => app.Use(async (context, next) =>
{
string nonce = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32));
context.Items[NonceKey] = nonce;
context.Response.Headers.ContentSecurityPolicy = string.Join("; ",
"default-src 'self'",
"base-uri 'self'",
"object-src 'none'",
"img-src 'self' data:",
$"script-src 'self' 'wasm-unsafe-eval' 'nonce-{nonce}' 'unsafe-hashes' {NavMenuToggleHash}",
"style-src 'self'",
// Virtualize sizes its spacer elements with style attributes; this allows attributes, not <style> blocks.
"style-src-attr 'unsafe-inline'",
"connect-src 'self'");
await next();
});
wasm-unsafe-eval is needed for the WebAssembly runtime. ImportMap writes an inline <script type="importmap"> into the page; its content changes with the fingerprints, so we allow it with a nonce instead of a hash, and App.razor gives the nonce to the component:
@code {
[CascadingParameter]
private HttpContext? HttpContext { get; set; }
// Set by UseContentSecurityPolicy; the inline import map is allowed by this nonce.
private string? Nonce => HttpContext?.Items[Bookstore.Admin.Security.ContentSecurityPolicy.NonceKey] as string;
}
The inline onclick in the template's NavMenu is allowed by the hash from the documentation. The first attempt had style-src 'self', which allows no style attributes. I went through the pages, then scrolled the long list's Virtualize:
scrollHeight 858px, last rendered row after scrolling to the end: 26, CSP violations: 5
Moving our own style="..." attributes into scoped CSS brought 42 violations down to 5; the remaining five are the heights Virtualize gives its spacer elements. With those blocked, the list stops at the first 26 rows instead of 10,000. Since Blazor writes these attributes into the DOM itself, the only way is style-src-attr 'unsafe-inline': it allows style attributes but not <style> blocks. The result:
scrollHeight 330000px, last rendered row after scrolling to the end: 10000, CSP violations: 0
Content-Security-Policy: default-src 'self'; base-uri 'self'; object-src 'none'; img-src 'self' data:; script-src 'self' 'wasm-unsafe-eval' 'nonce-…' 'unsafe-hashes' 'sha256-qnHnQs7NjQNHHNYv/I9cW+I62HzDJjbnyS/OFzqlix0='; style-src 'self'; style-src-attr 'unsafe-inline'; connect-src 'self', frame-ancestors 'none'
/inventory: 0 violation(s)
/books/9780141439587: 0 violation(s)
/reports/sales: 0 violation(s)
/diagnostics/claims: 0 violation(s)
/diagnostics/long-list: 0 violation(s)
/authors: 0 violation(s)
The frame-ancestors 'none' at the end comes from a separate header: Blazor adds frame-ancestors 'self' by itself for interactive server rendering. The panel doesn't need to open inside an iframe, so we made it 'none' with ContentSecurityFrameAncestorsPolicy. Chart.js, Bootstrap and ReconnectModal caused no violations under this policy; Chart.js sets its styles through the CSSOM, which CSP doesn't block.
Behind a Reverse Proxy
In production a reverse proxy usually sits in front of Kestrel. Because a circuit is a long-lived WebSocket connection, the proxy has to pass on the Upgrade and Connection headers. The sample Nginx configuration in the snapshot:
# Bookstore.Admin behind Nginx. The Upgrade/Connection headers let the Blazor circuit use WebSockets;
# without them SignalR falls back to long polling. TLS termination is left out of this example.
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
listen 80;
server_name admin.bookstore.example;
location / {
proxy_pass http://127.0.0.1:5037;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
# $http_host keeps the port; $host drops it, and the panel's redirects then point to the wrong address.
proxy_set_header Host $http_host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# A circuit is a long-lived connection; SignalR's keep-alive pings every 15 seconds keep it below this.
proxy_read_timeout 100s;
proxy_buffering off;
}
}
I tried it in an Nginx container forwarding port 8080 to the panel, once with these headers and once without:
with Upgrade/Connection: WebSockets opened: /_blazor; long-polling GETs in 5 s: 0
console: Information: WebSocket connected to ws://localhost:8080/_blazor?id=….
without them: WebSockets opened: /_blazor; long-polling GETs in 5 s: 7
console: WebSocket connection to 'ws://localhost:8080/_blazor?id=… failed: Error during WebSocket handshake: Unexpected response code: 200
console: Information: (WebSockets transport) There was an error with the transport.
console: Error: Failed to start the transport 'WebSockets': Error: WebSocket failed to connect. The connection could not be found on the server, either the endpoint may not be a SignalR endpoint, the connection ID is not present on the server, or there is a proxy blocking WebSockets. If you have multiple servers check that sticky sessions are enabled.
console: Warning: Failed to connect via WebSockets, using the Long Polling fallback transport. This may be due to a VPN or proxy blocking the connection. To troubleshoot this, visit https://aka.ms/blazor-server-using-fallback-long-polling.
The answer to the third question: the panel keeps working, but when the WebSocket handshake fails SignalR falls back to long polling; seven HTTP requests in five seconds, extra latency on every interaction and more work on the server. The user may not notice anything, so you have to watch the console warning or the transport on the server side. The first attempt had $host in the Host header:
$ curl -i http://localhost:8080/inventory
HTTP/1.1 302 Found
Location: http://localhost/account/login?ReturnUrl=%2Finventory
$host drops the port; on the standard 80/443 ports that's fine, but on another port the login redirect goes to the wrong address. If TLS terminates at the proxy, you need to turn on forwarded headers so the app sees the real scheme and client IP; the details are in the reverse proxy post of the Web Servers and Hosting series. With more than one server, a user's connection must always reach the same server (sticky sessions), because the circuit lives in that server's memory; Azure SignalR Service solves this by holding the connections itself and routing the user to the same server. Scale-out was covered in the scale-out post of the Real-time Applications series; the Azure side wasn't run in this post. A standalone app that only uses WebAssembly needs no server at all; its wwwroot output can go to any static host.
In Production
In post 09 we said the panel connects to the database only to read; in production that should be guaranteed by permissions too. Since post 11 the panel also writes to its own identity schema. On SQL Server it looks roughly like this (this script wasn't run in this post):
CREATE USER bookstore_admin FOR LOGIN bookstore_admin;
GRANT SELECT ON SCHEMA::catalog TO bookstore_admin;
GRANT SELECT ON SCHEMA::inventory TO bookstore_admin;
GRANT SELECT ON SCHEMA::ordering TO bookstore_admin;
GRANT SELECT, INSERT, UPDATE, DELETE ON SCHEMA::identity TO bookstore_admin;
Migrations should run in the deploy step with a separate, privileged user, not the app's user; the automatic migration from Development is off in production.
The author endpoint from post 07 first queries the ISBNs and then saves. If two requests arrive at the same time, both can pass the check and the unique index stops the second; in that case the API should return the same validation error instead of a 500. The 32 KB MaximumReceiveMessageSize limit from post 10 should stay in place in production too. Persisted state is a cost as well: in post 06 the state embedded in the page during prerendering for the 20-row stock list was 4,656 characters, added to the HTML on every load.
Common Pitfalls
Measuring size on a Debug build. Trimming, compression and relinking only exist in a Release publish.
Turning AOT on without measuring. The first load gets twice as heavy; the gain is only in CPU-heavy code.
Forgetting disconnected circuits. A closed tab stays in memory until the period runs out; those count in the memory budget too.
Not trying the CSP on every page before enforcing it. Breakage like Virtualize's shows no error; the violations in the console are the only clue.
Forgetting the WebSocket headers in the proxy. Because the panel keeps working with long polling, the problem only shows when you measure.
Running several servers without sticky sessions. If the connection goes to another server, the circuit isn't found and the user reloads the page.
A Small Experiment
★☆☆ Add InvariantGlobalization to the Client project and publish. How much smaller does _framework get, and does the format of amounts in the panel change?
★★☆ Show the race in the author endpoint with a test: use a SaveChangesInterceptor that inserts a book with the same ISBN right before the save, then write the fix that catches DbUpdateException and returns 400.
★★★ Run two copies of the panel behind Nginx with an upstream. What happens to the circuit without sticky sessions, and with ip_hash?
With that, the panel is in production. The last post of the series looks back: what changed in Blazor from .NET 8 to .NET 11, what happens when the panel is upgraded to .NET 11, and a checklist for the upgrade.