Learnixo
Back to blog
Backend Systemsintermediate

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.

LearnixoJune 4, 20266 min read
.NETC#BlazorSignalRReal-TimeServer-SideFrontend
Share:𝕏

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

Bash
dotnet new blazorserver -n OrderFlow.Admin

.NET 8+ uses the new Interactive Server render mode within a unified Blazor Web App:

Bash
dotnet new blazor -n OrderFlow.Admin --interactivity Server

Components and Lifecycle

RAZOR
@* 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

RAZOR
@* Passing data to child components *@
<OrderTable Orders="_recentOrders" OnOrderSelected="HandleSelected" />

@* Cascading values  available to entire subtree without explicit params *@
<CascadingValue Value="_currentUser">
    <MainContent />
</CascadingValue>
RAZOR
@* 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:

RAZOR
@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;
    }
}
C#
// 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:

RAZOR
@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

RAZOR
<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

RAZOR
@* _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:

JSON
// 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:

C#
builder.Services.AddSignalR().AddAzureSignalR(connectionString);
// or
builder.Services.AddSignalR().AddStackExchangeRedis(redisConnectionString);

State Management

Blazor Server has three natural state scopes:

C#
// 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:

C#
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?

Share:𝕏

Leave a comment

Have a question, correction, or just found this helpful? Leave a note below.