HTML over WebSockets: wat Blazor Server je wel en niet geeft

Een populaire Hacker News-post viert HTML over WebSockets als de manier om SPA's te bouwen met bijna geen JavaScript. Voor .NET-teams bestaat dat al: Blazor Server. Hier is de praktijk, met de kosten die niemand op de slide zet.

Jean-Pierre Broeders

Freelance .NET Developer

13 augustus 202610 min. leestijd
HTML over WebSockets: wat Blazor Server je wel en niet geeft

Er staat deze week een post hoog op Hacker News die een oud idee opnieuw verpakt: stuur geen JSON naar de browser om daar met een framework HTML in elkaar te zetten, maar render de HTML op de server en schuif hem via een open verbinding op zijn plek. De auteur zet Phoenix LiveView, Hotwire en Laravel Livewire naast elkaar en noemt de aanpak "real-time SPA's met bijna geen JavaScript". En ergens in de tabel, tussen Elixir en PHP, staat C# met Blazor Interactive Server.

Dat regeltje verdient meer dan een voetnoot. Want als je .NET schrijft, is dit geen toekomstmuziek. Blazor Server doet dit al sinds 2019, en ik heb er genoeg productie-apps op zien draaien om te weten waar de aanpak zingt en waar hij pijn doet.

Het idee klopt, de marketing niet

De belofte is verleidelijk. Eén taal, één render-engine, geen API-contract dat je aan twee kanten moet onderhouden, geen aparte frontend-build die om de zes weken van framework wisselt. Je businesslogica en je UI-logica wonen in hetzelfde project, in dezelfde taal, en de gebruiker krijgt een interface die aanvoelt als een SPA.

Waar de post te makkelijk overheen stapt is de zin "bijna geen JavaScript". Dat klopt letterlijk, en het is misleidend. Je schrijft geen JavaScript, maar de complexiteit verdwijnt niet. Hij verhuist. Van de browser naar de server, en van jouw code naar de plek waar je hem het slechtst kunt zien: het netwerk en het geheugen van je app-servers. Dat is een echte keuze met echte gevolgen, en die zijn het waard om nuchter te bekijken voordat je een team op Blazor Server zet.

Wat Blazor Server letterlijk doet

Hier wijkt .NET af van LiveView, en het is een verschil dat ertoe doet. LiveView en Hotwire sturen HTML-fragmenten over de lijn. Blazor Server stuurt geen HTML. Het houdt op de server een render-tree bij per verbonden gebruiker, en bij elke UI-wijziging berekent het een diff van die tree. Alleen die diff gaat over de WebSocket, als een binaire RenderBatch. De JavaScript-runtime in de browser is klein en dom: hij ontvangt de diff en past het DOM aan.

Die verbinding heet een circuit. Eén circuit per browser-tab, gedragen door een SignalR-hub, en zolang de tab open is leeft de volledige component-state van die gebruiker in het geheugen van jouw server.

Een teller laat het minimalisme zien.

@page "/teller"
@rendermode InteractiveServer

<h1>Stand: @count</h1>
<button @onclick="Increment">Plus een</button>

@code {
    private int count;

    private void Increment() => count++;
}

Geen fetch, geen endpoint, geen JSON, geen state-management-bibliotheek. Je klikt, en count wordt opgehoogd. Wat je op de slide niet ziet: die klik reist naar de server, de server voert Increment uit, berekent de diff, stuurt hem terug, en de browser tekent opnieuw. Voor een teller op een kantoornetwerk merk je daar niets van. Voor een gebruiker op 4G in de trein is elke klik een rondje naar Amsterdam en terug.

De latency zit in de interactie, niet in de laadtijd

Dit is de val waar mensen intrappen. Ze testen Blazor Server op localhost, waar de round-trip nul milliseconden is, en concluderen dat het snel voelt. Op localhost voelt alles snel.

Zet dezelfde app achter honderd milliseconden netwerk en het karakter verandert. Elke interactie die de server moet zien wordt honderd milliseconden vertraagd. Een enkele klik is prima. Een tekstveld dat bij elke toetsaanslag valideert wordt een marteling, want je stuurt een event per letter.

<input @bind="filter" @bind:event="oninput" />

Met oninput gaat er een bericht naar de server bij iedere aanslag. Op een trage lijn zie je je eigen typen achterlopen. De reflex is dit client-side op te lossen, maar dan schrijf je precies de JavaScript die je zou vermijden. De eerlijke oplossing is debouncen of pas op onchange reageren, en accepteren dat sommige interacties nou eenmaal thuishoren in de browser. Blazor WebAssembly of een stukje echte JS via IJSRuntime bestaan om die reden.

Geheugen is de tweede rekening

Elk circuit kost geheugen. Component-instanties, hun velden, de vorige render-tree om tegenaan te diffen, buffers voor nog niet bevestigde batches. Voor tien gelijktijdige gebruikers is dat niets. Voor tienduizend is het een capaciteitsvraag die je vooraf moet beantwoorden, niet als de server omvalt.

Standaard houdt Blazor een losgekoppeld circuit nog even vast zodat een gebruiker die kort zijn wifi verliest terugkomt op dezelfde plek. Fijn voor de gebruiker, maar het betekent dat afgesloten tabs nog even geheugen bezet houden. Die knoppen wil je bewust zetten.

builder.Services.AddServerSideBlazor()
    .AddCircuitOptions(o =>
    {
        o.DetailedErrors = false;
        o.DisconnectedCircuitRetentionPeriod = TimeSpan.FromMinutes(2);
        o.DisconnectedCircuitMaxRetained = 100;
    })
    .AddHubOptions(o => o.MaximumReceiveMessageSize = 64 * 1024);

En omdat de state op één specifieke server leeft, moet elk request van een gebruiker naar diezelfde server. Draai je meer dan één instance, dan heb je sticky sessions nodig, of je zet de Azure SignalR Service ertussen als backplane. Doe je geen van beide, dan verliest een gebruiker zijn circuit zodra de load balancer hem naar een andere pod stuurt, en dat gebeurt op het slechtst denkbare moment.

Wanneer ik hiervoor kies

Blazor Server is op zijn best voor intern gereedschap. Een admin-paneel, een orderdashboard, een backoffice-app voor een handjevol tot een paar honderd gebruikers die op een snel netwerk zitten. Daar is de latency verwaarloosbaar, het geheugen geen probleem, en de winst enorm: je bouwt een rijke, interactieve UI zonder ook maar één API-endpoint of frontend-framework aan te raken. Ik heb zo een intern platform in weken opgeleverd waar een React-plus-API-variant maanden had gekost.

Voor een publieke site met tienduizenden bezoekers op wisselende netwerken zou ik het niet doen. De latency per interactie en het geheugen per verbinding werken daar tegen je. Dan is er een aanpak die dichter bij de HN-post staat en beter schaalt.

De stateless neef: htmx met gewoon Razor

De post noemt htmx als de HTTP-variant van dezelfde familie, en die combineert prachtig met ASP.NET Core. Je server rendert een HTML-fragment, htmx haalt het op en schuift het in het DOM. Geen circuit, geen server-state, geen sticky sessions. Gewoon een request, een partial, een antwoord.

<div hx-get="/orders/live" hx-trigger="every 3s" hx-swap="innerHTML">
  <p>Laden...</p>
</div>
app.MapGet("/orders/live", async (IOrderQuery query) =>
{
    var orders = await query.RecentAsync();
    // Razor-partial gerenderd naar een string, teruggegeven als HTML.
    return Results.Extensions.RazorPartial("_OrderRows", orders);
});

Dit schaalt als elke andere stateless HTTP-endpoint, want dat is het ook. Je betaalt met een klein beetje JavaScript (de htmx-library, elf kilobyte) en met het feit dat je voor echte real-time push toch iets extra's nodig hebt. Voor de meeste "ververs deze lijst elke paar seconden"-schermen is polling met htmx meer dan genoeg, en oneindig makkelijker te bedrijven dan een vloot circuits.

Als je echt push nodig hebt: SignalR direct

Voor een dashboard dat live moet updaten zonder te pollen, of een chat, of een orderscherm dat piept zodra er iets binnenkomt, pak je SignalR rechtstreeks. Dat is dezelfde transportlaag waar Blazor Server op draait, maar dan zonder de component-state per gebruiker. Jij bepaalt wat er over de lijn gaat.

using Microsoft.AspNetCore.SignalR;

public sealed class MetricsHub : Hub;

public sealed class MetricsBroadcaster(IHubContext<MetricsHub> hub)
    : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken ct)
    {
        while (!ct.IsCancellationRequested)
        {
            var snapshot = await ReadSnapshotAsync(ct);
            await hub.Clients.All.SendAsync("metrics", snapshot, ct);
            await Task.Delay(TimeSpan.FromSeconds(2), ct);
        }
    }

    private static Task<object> ReadSnapshotAsync(CancellationToken ct) =>
        Task.FromResult<object>(new { cpu = 41, queue = 7 });
}

Aan de clientkant is dat een handvol regels JavaScript die een metrics-event opvangt en een teller bijwerkt. Je stuurt hier bewust JSON, geen HTML, want een cijfer is geen HTML waard. De les uit de HN-post is niet dat HTML over de socket altijd de winst is. De les is dat een open verbinding je opties geeft, en dat je per scherm mag kiezen wat je erover stuurt.

De echte afweging

De trend die op Hacker News wordt gevierd is echt, en .NET zit er al jaren in. Maar "bijna geen JavaScript" is een prijskaartje dat de helft van de kosten weglaat. Je ruilt clientcomplexiteit in voor server-state, geheugen per verbinding en latency per interactie. Dat is een goede ruil voor interne apps op snelle netwerken, en een slechte voor publieke apps op trage. De kunst zit niet in het kiezen van een kamp. Hij zit in het per scherm weten welke van de drie, Blazor Server, htmx of kale SignalR, bij dat scherm past.

Veelgestelde vragen over HTML over WebSockets in .NET

Stuurt Blazor Server echt HTML over de WebSocket? Nee, en dat is het verschil met LiveView en Hotwire. Blazor Server houdt op de server een render-tree bij per gebruiker en stuurt bij elke wijziging alleen een binaire diff van die tree, een RenderBatch, over de SignalR-verbinding. De browser krijgt de diff binnen en past het DOM aan. Dat is doorgaans zuiniger dan complete HTML-fragmenten sturen, maar het betekent wel dat de server de volledige UI-state van elke actieve gebruiker in geheugen houdt.

Wat is het verschil tussen Blazor Server en htmx voor een .NET-team? Blazor Server is stateful: elke gebruiker heeft een circuit met server-state, wat interactieve UI zonder API's oplevert maar geheugen en sticky sessions kost. htmx is stateless: je server rendert een HTML-fragment per request en htmx schuift het in het DOM, precies als elke andere HTTP-endpoint. Voor rijke interne apps op een snel netwerk wint Blazor Server aan gemak, voor publieke schermen die simpel moeten schalen wint htmx met Razor.

Waarom voelt mijn Blazor Server-app traag aan bij het typen? Omdat elke interactie die de server moet zien een netwerk-round-trip is, en een tekstveld met oninput stuurt een bericht per toetsaanslag. Op localhost merk je dat niet, op een echte verbinding met honderd milliseconden latency loopt je typen achter. Debounce de invoer, reageer waar het kan op onchange in plaats van oninput, of handel puur visuele interacties af in de browser zelf.

Heb ik de Azure SignalR Service nodig voor Blazor Server? Zodra je meer dan één server-instance draait wel, of je moet sticky sessions op je load balancer zetten. De circuit-state van een gebruiker leeft op precies één instance, dus elk volgend bericht moet naar diezelfde instance. De Azure SignalR Service neemt die verbindingen over als backplane zodat je horizontaal kunt schalen zonder dat gebruikers hun circuit verliezen bij een herverdeling.

Wanneer kies ik SignalR direct in plaats van Blazor Server? Wanneer je echte server-push wilt zonder de component-state per gebruiker: een live dashboard, een chat, een scherm dat moet updaten zodra er een event binnenkomt. Dan bepaal je zelf wat er over de lijn gaat, vaak een compacte JSON in plaats van UI-diffs, en je betaalt niet voor een volledig circuit per verbonden tab. Het is dezelfde transportlaag als onder Blazor Server, maar zonder de state-rekening.

Verder lezen


Conclusie: HTML over WebSockets is voor .NET geen nieuwe truc maar een bekende met een prijskaartje. Kies Blazor Server voor interne apps op snelle netwerken, htmx met Razor voor publieke schermen die simpel moeten schalen, en kale SignalR wanneer je echte push nodig hebt. Per scherm beslissen verslaat het kiezen van een kamp.

Bronnen: HTML over WebSockets · Hacker News-discussie.

Wil je op de hoogte blijven?

Schrijf je in voor mijn nieuwsbrief of neem contact op voor freelance projecten.

Neem Contact Op