Blazor Server: Real-Time .NET UI Without JavaScript
Build interactive server-side Blazor apps. Covers the SignalR circuit model, components, state management, real-time updates, streaming rendering, scalability, and when to choose Blazor Server.
How Blazor Server Works
Blazor Server runs your C# UI code on the server. A SignalR connection (WebSocket) streams UI diffs to the browser. The browser has a tiny JavaScript shim — no .NET runtime download required.
Browser Server
│ │
│ User clicks button │
│──── SignalR event ────────►│
│ │ C# event handler runs
│ │ DOM diff computed
│◄─── UI diff (bytes) ───────│
│ Browser patches DOM │Advantages:
- Instant startup (no WASM download)
- Full .NET BCL, EF Core, any NuGet package — server has everything
- Always up to date — no caching issues with old DLL versions
- Great for internal dashboards, admin tools, real-time monitoring
Disadvantages:
- Requires persistent server connection — flaky network = broken UI
- Server holds memory per active circuit (~250KB baseline per user)
- Not suitable for massive public scale without careful tuning
Setup
dotnet new blazorserver -n OrderFlow.Admin.NET 8+ uses the new Interactive Server render mode within a unified Blazor Web App:
dotnet new blazor -n OrderFlow.Admin --interactivity ServerComponents and Lifecycle
@* Pages/Dashboard.razor *@
@page "/dashboard"
@rendermode InteractiveServer
@inject IOrderRepository OrderRepo
@inject IHubContext<NotificationHub> Hub
@implements IAsyncDisposable
<h1>Live Dashboard</h1>
<div class="row">
<div class="col-md-3">
<div class="card text-bg-primary">
<div class="card-body">
<h2>@_pendingCount</h2>
<p>Pending Orders</p>
</div>
</div>
</div>
</div>
<table class="table mt-4">
<tbody>
@foreach (var order in _recentOrders)
{
<tr class="@(_newOrderIds.Contains(order.Id) ? "table-warning" : "")">
<td>@order.Id</td>
<td>@order.CustomerName</td>
<td>@order.Total.ToString("C")</td>
<td>@order.CreatedAt.ToString("HH:mm:ss")</td>
</tr>
}
</tbody>
</table>
@code {
private List<OrderDto> _recentOrders = new();
private int _pendingCount;
private HashSet<Guid> _newOrderIds = new();
private Timer? _refreshTimer;
protected override async Task OnInitializedAsync()
{
await LoadDataAsync();
// Refresh every 5 seconds
_refreshTimer = new Timer(async _ =>
{
await LoadDataAsync();
await InvokeAsync(StateHasChanged); // marshal to circuit thread
}, null, TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(5));
}
private async Task LoadDataAsync()
{
_recentOrders = await OrderRepo.GetRecentAsync(50);
_pendingCount = await OrderRepo.CountPendingAsync();
}
public async ValueTask DisposeAsync()
{
if (_refreshTimer is not null)
await _refreshTimer.DisposeAsync();
}
}Component Parameters and Cascading Values
@* Passing data to child components *@
<OrderTable Orders="_recentOrders" OnOrderSelected="HandleSelected" />
@* Cascading values — available to entire subtree without explicit params *@
<CascadingValue Value="_currentUser">
<MainContent />
</CascadingValue>@* Child consuming cascade *@
@code {
[CascadingParameter] private UserInfo? CurrentUser { get; set; }
}Real-Time with SignalR
Blazor Server already uses SignalR for its own circuit. You can push server-initiated updates directly:
@page "/orders/live"
@rendermode InteractiveServer
@inject OrderEventService EventService
@implements IDisposable
<h2>Live Orders Feed</h2>
@foreach (var order in _orders)
{
<div class="alert alert-info">
New order @order.Id from @order.CustomerName — @order.Total.ToString("C")
</div>
}
@code {
private List<OrderDto> _orders = new();
protected override void OnInitialized()
{
EventService.OnOrderCreated += HandleNewOrder;
}
private void HandleNewOrder(OrderDto order)
{
_orders.Insert(0, order);
if (_orders.Count > 20) _orders.RemoveAt(_orders.Count - 1);
InvokeAsync(StateHasChanged); // required — event fires on background thread
}
public void Dispose()
{
EventService.OnOrderCreated -= HandleNewOrder;
}
}// Singleton service that bridges domain events to Blazor components
public class OrderEventService
{
public event Action<OrderDto>? OnOrderCreated;
public void NotifyOrderCreated(OrderDto order)
=> OnOrderCreated?.Invoke(order);
}Streaming Rendering (.NET 8+)
Stream HTML to the browser before the page finishes loading data:
@page "/orders/{id:guid}"
@attribute [StreamRendering] // starts sending HTML immediately
<h1>Order Details</h1>
@if (_order is null)
{
<p>Loading...</p> @* shown immediately while awaiting *@
}
else
{
<OrderDetail Order="_order" />
}
@code {
[Parameter] public Guid Id { get; set; }
private OrderDto? _order;
protected override async Task OnInitializedAsync()
{
_order = await OrderService.GetOrderAsync(Id); // streams HTML before this completes
}
}Forms
<EditForm Model="_model" OnValidSubmit="HandleSubmit" FormName="create-order">
<DataAnnotationsValidator />
<div class="mb-3">
<label class="form-label">Customer</label>
<InputText class="form-control" @bind-Value="_model.CustomerId" />
<ValidationMessage For="() => _model.CustomerId" />
</div>
<button type="submit" class="btn btn-primary">Submit</button>
</EditForm>
@code {
[SupplyParameterFromForm]
private CreateOrderModel _model { get; set; } = new();
private async Task HandleSubmit()
{
await OrderService.CreateAsync(_model);
Nav.NavigateTo("/orders");
}
}Error Handling
@* _Imports.razor or individual pages *@
<ErrorBoundary>
<ChildContent>
<OrdersTable />
</ChildContent>
<ErrorContent Context="ex">
<div class="alert alert-danger">
Something went wrong: @ex.Message
</div>
</ErrorContent>
</ErrorBoundary>Scalability: Sticky Sessions
Because circuit state lives in server memory, load balancers must route each user back to the same server (sticky sessions). Configure in Azure App Service:
// Azure App Service — ARR affinity
"siteProperties": { "clientAffinityEnabled": true }For multi-server without sticky sessions, use Azure SignalR Service or Redis backplane — same as SignalR scaling:
builder.Services.AddSignalR().AddAzureSignalR(connectionString);
// or
builder.Services.AddSignalR().AddStackExchangeRedis(redisConnectionString);State Management
Blazor Server has three natural state scopes:
// 1. In-component — lost on navigation
private List<Order> _orders = new();
// 2. Scoped (per circuit/user) — survives navigation within session
builder.Services.AddScoped<CartState>();
// 3. Singleton (all users) — shared state, must be thread-safe
builder.Services.AddSingleton<AppMetrics>();For persistent state across sessions, use the browser's localStorage via JS interop:
await JS.InvokeVoidAsync("localStorage.setItem", "cart", JsonSerializer.Serialize(cart));
var stored = await JS.InvokeAsync<string>("localStorage.getItem", "cart");Interview Questions
Q: What is a Blazor Server circuit? A SignalR connection between the browser and server that represents one user's active session. The server holds all component state in memory for the circuit. If the connection drops, the circuit is lost. Each active user consumes server memory.
Q: Why must you call InvokeAsync(StateHasChanged) from background threads?
Blazor Server components run on a single-threaded circuit dispatcher. When a background thread (Timer callback, event handler) modifies component state, it must marshal back to the circuit thread using InvokeAsync. Calling StateHasChanged directly from a background thread causes race conditions.
Q: How do you scale Blazor Server to multiple instances? Because circuit state lives in one server's memory, load balancers need sticky sessions (route same user to same server). For true multi-server scaling without sticky sessions, offload the SignalR backplane to Azure SignalR Service or Redis — Blazor Server circuits are then distributed across the backplane.
Q: What is Streaming Rendering in .NET 8 Blazor?
The [StreamRendering] attribute tells Blazor to begin sending HTML to the browser immediately, before async operations complete. The browser shows placeholder content while data is loading, then the page is updated in place — improving perceived performance without JavaScript.
Enjoyed this article?
Explore the Backend Systems learning path for more.
Found this helpful?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.