RE:NODE

App hosting11 min read

Blazor Server vs WebAssembly: what each costs to host

How Blazor Server circuits and Blazor WebAssembly differ once hosted: memory per user, SignalR, latency, download size, prerendering and the Blazor Web App modes.

0 readers

Blazor Server runs your components on the server and sends UI updates to the browser over a SignalR connection: every open tab is a live circuit holding state in server memory, every click is a round trip, and the server's memory grows with concurrent users rather than with requests. Blazor WebAssembly downloads the .NET runtime and your app into the browser and runs it there: the server only serves static files and an API, but the first visit costs a multi-megabyte download and nothing in the client can be secret. Since .NET 8 you no longer have to pick one for the whole app - the Blazor Web App model renders pages statically on the server by default and lets each component choose Server, WebAssembly or Auto interactivity.

For hosting, the question is simple: how many people will be connected at once, how far are they from the server, and can the server afford to remember all of them? This post puts numbers on each of those.

The render modes in one table#

A Blazor Web App (the default template since .NET 8) supports four ways to render a component:

Render modeWhere code runsInteractiveServer cost per visitor
Static server-side (SSR)Server, per requestNo (forms and links only)A normal HTTP request
InteractiveServerServer, in a circuitYesA live circuit for the whole visit
InteractiveWebAssemblyBrowserYesStatic files, then API calls
InteractiveAutoServer first, then browserYesA circuit until the runtime is cached

Static SSR is easy to overlook and often the right answer. A page that shows data and posts a form needs no interactivity at all; it renders like a Razor Page and costs the server nothing after the response is sent. Enhanced navigation makes links between static pages feel like a single-page app, and [StreamRendering] lets a slow page send its layout first and the data when it arrives.

Interactivity is set per component or for the whole app:

Program.cs
builder.Services.AddRazorComponents()    .AddInteractiveServerComponents()    .AddInteractiveWebAssemblyComponents();app.MapRazorComponents<App>()    .AddInteractiveServerRenderMode()    .AddInteractiveWebAssemblyRenderMode()    .AddAdditionalAssemblies(typeof(Client._Imports).Assembly);

A component then declares @rendermode InteractiveServer (or one of the others), and only that component and its children pay for interactivity. The older standalone Blazor Server and "ASP.NET Core hosted" WebAssembly templates are gone from .NET 8 onwards; existing apps built on them still run.

How Blazor Server works#

When a page with an interactive server component loads, the browser opens a SignalR connection back to the server, preferably over WebSockets. The server creates a circuit: an instance of every interactive component on the page, their state, the render tree, and a scoped dependency injection container. From then on:

  1. The user clicks a button. The browser sends the event over the connection.
  2. The server runs the event handler, re-renders the affected components, and computes the difference from the last render.
  3. The server sends the diff back, and the browser patches the DOM.
SignalRevent handlersBrowser tabblazor.web.jsReverse proxyWebSocket upgradeCircuitcomponent stateDatabasequeries
One click in a Blazor Server app

This is why Blazor Server feels like writing a desktop app: the code can call the database directly, there is no API layer to build, and nothing sensitive ever reaches the browser. It is also why its costs are unlike a normal web app's. A circuit lives as long as the tab is open, whether the user is doing anything or not, and the scoped services in it - including an EF Core DbContext if you inject one directly - live just as long.

Memory and CPU per user#

Microsoft's own guidance puts the minimum at roughly 250 KB of server memory per connected user for a very simple app. Real apps hold far more: every component's fields, any data you loaded into them, grids with thousands of rows, and the scoped services. A circuit holding a 5,000-row grid with its data is measured in megabytes, and it holds it for the whole visit.

A rough way to size it: measure one circuit. Open the app in one tab, note the memory graph, open twenty more tabs on the heaviest page, and divide the increase by twenty. Then multiply by the number of people you expect to have the app open at the same moment - not per day, but at once.

Concurrent usersLight pages (about 0.5 MB each)Heavy pages (about 5 MB each)
2010 MB100 MB
200100 MB1 GB
1,000500 MB5 GB

Those per-user figures are illustrative, not measured on your app; the point is the shape. A Blazor Server app that is fine with an office full of users can be wrong for a public site where thousands might arrive at once. .NET memory and garbage collection covers the base memory of the process on top of the circuits.

The circuit options that bound this:

CircuitOptions settingDefaultWhat it does
DisconnectedCircuitRetentionPeriod3 minutesHow long a dropped circuit is kept for reconnection
DisconnectedCircuitMaxRetained100How many dropped circuits are kept at once
JSInteropDefaultCallTimeout1 minuteTimeout for calls into JavaScript
MaxBufferedUnacknowledgedRenderBatches10Render batches queued for a slow client
DetailedErrorsfalseSend exception details to the browser - development only

Keep data out of components when you can: load a page of results, not the table. Use Virtualize for long lists so only the visible rows are rendered. Create a DbContext per operation with IDbContextFactory<T> instead of injecting one for the life of the circuit.

CPU is usually the lesser problem, because most events are cheap. It matters when many users trigger expensive renders at once - a dashboard re-rendering on a timer for every connected user is a load that grows with the audience even when nobody touches anything.

Latency and the connection#

Every interaction in Blazor Server waits for the network. At 20 ms between the user and the server, a button feels instant. At 150 ms, typing into a field that re-renders on every keystroke feels sticky, and drag-and-drop feels broken. If your users are on another continent from the server, test from there before you commit to Server mode. Latency, jitter and packet loss explains what the numbers mean in practice.

The connection also has to survive. Blazor Server needs a persistent connection through every proxy between the browser and Kestrel:

  • WebSockets must pass the upgrade. If they cannot, SignalR falls back to long polling, which works but makes every event slower and costs more requests. The browser's network tab shows a 101 Switching Protocols response when WebSockets are working.
  • Idle timeouts on the proxy must be longer than SignalR's keep-alive, which pings every 15 seconds by default.
  • The app must see the real client and scheme through forwarded headers - see ASP.NET Core behind a reverse proxy.

When the connection drops, the browser shows a reconnection overlay and retries. If it reconnects within the retention period, the circuit resumes where it was. If not, state is gone and the user must reload. Mobile users switching networks and laptops waking from sleep hit this constantly, and .NET 9 and 10 have both improved the reconnection experience; .NET 10 adds a way to persist circuit state so a user can resume after a longer gap. Check what your version supports before relying on it.

How Blazor WebAssembly works#

WebAssembly mode ships your app to the browser. The first visit downloads the .NET runtime compiled to WebAssembly, the framework assemblies your app uses, and your own assemblies, then runs everything locally. After that, the browser caches the files and later visits start quickly.

The trade-offs mirror Server mode's:

  • The download. A published app is trimmed (on by default for WebAssembly) and compressed, but expect a first-load payload measured in megabytes rather than kilobytes - a small app lands somewhere around 2 to 4 MB compressed, a larger one more. On a slow mobile connection that is seconds of loading screen.
  • No secrets. Everything in the client can be read by the user. Connection strings, API keys and business rules that must not be bypassed belong on the server, behind an API.
  • Execution speed. By default your code is interpreted by the WebAssembly runtime. Ahead-of-time compilation (<RunAOTCompilation>true</RunAOTCompilation>, which needs the wasm-tools workload to build) makes CPU-heavy code much faster and makes the download considerably larger.
  • Server cost. Almost none for the UI. The server serves static files once per user and then answers API calls, which scale like any other API.

Hosting a WebAssembly app#

A standalone Blazor WebAssembly app (the blazorwasm template) publishes to a folder of static files under wwwroot, and any static host can serve it. Three details decide whether it works:

  1. Fallback routing. Blazor handles routes in the browser, so a request for /orders/42 must return index.html, not a 404. The static host needs a rewrite rule for unknown paths; how you add one depends on the server. Static site hosting covers the common cases.
  2. Compression and MIME types. Publishing writes pre-compressed .br and .gz copies of each file. Serving those, and serving .wasm as application/wasm, is the difference between a 3 MB and a 10 MB first load.
  3. Cache headers. The runtime files are fingerprinted, so they can be cached for a long time; index.html must not be, or users keep loading old versions after a deploy. HTTP caching headers explained has the reasoning.

The API the client calls is a separate ASP.NET Core app. If it is on a different origin from the static files, configure CORS on the API. In a Blazor Web App with InteractiveWebAssembly components, the ASP.NET Core server serves both the client files and the API from one origin, which avoids CORS entirely and is the simplest arrangement to host.

Prerendering and the double load#

Interactive components in a Blazor Web App are prerendered by default: the server renders the HTML once so the page appears immediately and search engines can read it, then the component starts again in interactive mode and renders a second time. Any data loading in OnInitializedAsync therefore runs twice - once during prerendering, once when the circuit or the WebAssembly runtime takes over.

Two ways to deal with it:

  • Persist the state across the two renders. PersistentComponentState (since .NET 8) serialises data from the prerender into the page and hands it to the interactive render. .NET 10 adds a declarative [PersistentState] attribute on a component property that does the same with less code.
  • Turn prerendering off for components where the first paint does not matter, with @rendermode @(new InteractiveServerRenderMode(prerender: false)). The page then shows nothing for that component until interactivity starts.

Prerendering also means code in OnInitializedAsync runs on the server even for a WebAssembly component, so it must not assume it is in a browser - no IJSRuntime calls there.

Deploys, restarts and lost circuits#

A deploy restarts the process, and every Blazor Server circuit dies with it. Users see the reconnection overlay, the reconnection fails because the server they knew has gone, and they have to reload, losing anything not saved. With deploy-on-push turned on, every push to the branch does this to every connected user.

So for a Blazor Server app: save user input often, deploy at quiet times, and do not treat the circuit as storage. WebAssembly apps are much more forgiving - the UI keeps running in the browser while the API restarts, and only calls made during the gap fail. Zero-downtime deploys on a small server covers what you can and cannot avoid with one instance.

One more thing that must survive a restart: the Data Protection keys used for antiforgery tokens and authentication cookies. If they are regenerated, every user is signed out. Deploy an ASP.NET Core app from GitHub shows where to persist them.

Choosing#

  • Mostly content and forms: static SSR, with interactivity only on the components that need it. Cheapest to host by far.
  • Internal tools and admin panels with tens of users near the server: InteractiveServer. No API to build, fast first load, memory is a non-issue at that scale.
  • Public apps with many concurrent users, or users far from the server: InteractiveWebAssembly with an API. The server scales with requests, not with open tabs.
  • You want fast first load and WebAssembly afterwards: InteractiveAuto, accepting that you now write components that must work in both places.

A worked example makes the split concrete. A booking site for a sports club has a public timetable, a booking form, and a staff screen for managing courts. The timetable is static SSR with stream rendering: it is read far more than anything else, and it costs nothing to keep open. The booking form is static SSR too - a normal form post with validation needs no circuit. The staff screen, used by four people on the club's own network, is InteractiveServer: rich, immediate, talking straight to the database. The result runs comfortably on a small plan, because the only circuits that exist belong to the four people who benefit from them. The same site built entirely in Server mode would hold a circuit for every member checking the timetable on a Saturday morning.

FAQ#

How many users can a Blazor Server app handle on 2 GB?

It depends entirely on what each circuit holds. A light app holding half a megabyte per user fits a few hundred concurrent users alongside the process itself; a data-heavy one may manage a few dozen. Measure one circuit and multiply.

Does Blazor WebAssembly need a .NET server at all?

Not for the UI. A standalone WebAssembly app is static files and can be served by any static host. You only need a .NET server for the API it calls - which you will almost always have.

Why does my Blazor Server app disconnect every minute?

A proxy idle timeout shorter than the keep-alive interval, or WebSockets not passing through so the fallback transport keeps timing out. Check the network tab for the transport in use.

Can I scale Blazor Server across several servers?

Yes, with sticky sessions so each user's connection always reaches the server holding their circuit. On a single server that question does not arise; SignalR real-time apps covers scale-out for plain SignalR.

Is InteractiveAuto the best of both?

It is a good default for public apps, but every Auto component must run both on the server and in the browser, so it cannot touch the database directly. That constraint is the price of the fast first load.


Comments

Completely anonymous: no account, no email, no cookie. We store the name you type, the text and the time - nothing else. Links are limited and markup is not rendered.

0/2000