MVC, Razor and SignalR
Server-rendered UI in ASP.NET Core and real-time updates: the path of a request from controller to HTML, Razor, protecting forms from CSRF, and SignalR on one server and on many.
Before this topic: Hosting and the pipeline.
How does MVC work in ASP.NET Core, and what path does a request take to View()?
Section titled “How does MVC work in ASP.NET Core, and what path does a request take to View()?”What it is
- Model — data and business logic: entities, services, rules.
- View — a
.cshtmlfile with HTML and Razor that displays data. - Controller — a class with action methods: accepts the request, calls the model, picks a view.
- ViewModel — a class shaped for one page: only the fields it shows or accepts.
How a request flows
- The template
{controller=Home}/{action=Index}/{id?}turns/Orders/Details/42into a call toOrdersController.Details(42). - The action loads data and builds a ViewModel.
return View(vm)looks forViews/Orders/Details.cshtml, thenViews/Shared/Details.cshtml.- Razor fills the markup with data, and the browser receives ready HTML.
Common mistake
Putting business logic straight into the controller. Then you can’t reuse it or test it without HTTP. A controller should only connect the request, a service and a view.
Takeaway: the controller accepts the request, the model supplies data, the view displays it, and View(vm) ties them together by the action name.
How it works
- The request passes through the middleware pipeline, and routing selects an endpoint — a specific action.
- MVC creates the controller. The class itself isn’t registered in DI, but its dependencies come from the request scope.
- Filters and model binding prepare the call: parameters are filled from the route, query string and form (details in the Web API topic).
- The action returns an
IActionResult.View(vm)creates aViewResult— a description of the result, not HTML. - After the action returns, the
ViewResultis executed: the Razor engine finds the view, runs_ViewStart, and renders the view and the layout.
When to choose what
| Way to pass data | When |
|---|---|
ViewModel and @model |
the default: typing and IntelliSense |
ViewData, ViewBag |
small things for the layout, such as the page title |
TempData |
a message that survives one redirect |
Pitfalls
- Deferred rendering. The view runs after the action. An
IQueryableor lazily loaded entities in the model mean database queries during rendering, often N+1. - Name casing. Views are found by file name. On Linux the file system is case-sensitive, and
details.cshtmlwon’t be found for theDetailsaction. - Entities as input. A POST bound directly to an EF entity lets a client set fields that aren’t on the form (overposting).
Rule of thumb
Keep actions thin: data from a service, a materialized ViewModel, View. After a successful POST, RedirectToAction (Post/Redirect/Get) so that F5 doesn’t resubmit the form.
Takeaway: View() returns a description of the result, and the HTML appears later, when the ViewResult executes.
Under the hood
- At startup
ApplicationPartManagerdiscovers controllers in assemblies, anApplicationModelis built and conventions (IApplicationModelConvention) are applied. Each action becomes anActionDescriptor, and those become routing endpoints. - Per request,
ControllerActionInvokerruns the pipeline: authorization and resource filters, model binding, action filters, the action itself, result filters. - Since .NET 6 the Razor source generator compiles views into the main app assembly: each one is a class deriving from
RazorPage<TModel>, and there is no separate*.Views.dllany more. ViewResultExecutorlocates the view (lookups are cached), renders it into a pooled buffer and then sends it to the response.
Cost
- Every request means creating a controller, binding, filters and Razor rendering. For JSON endpoints Minimal APIs are cheaper.
- A large page sits in the buffer until rendering finishes.
await FlushAsync()in a view sends the beginning earlier, but after that you can’t change the status code or headers.
Trade-offs
- Microsoft recommends Blazor for new projects. MVC and Razor Pages are supported and can embed Blazor Razor components into existing views.
- Razor runtime compilation (
AddRazorRuntimeCompilation) is obsolete since .NET 10, warning ASPDEPR003. Use Hot Reload in development and build-time compilation in production.
How to check in production
- The MVC Information-level log “Executed ViewResult - view Details executed in 312ms” shows rendering time separately from the action.
- The
http.server.request.durationmetric (.NET 8+) with thehttp.routetag shows slow routes.
Takeaway: MVC is a pipeline around the action, and the most expensive part is often not the action but rendering the view.
using Microsoft.AspNetCore.Mvc;
var builder = WebApplication.CreateBuilder(args);builder.Services.AddControllersWithViews(); // controllers + Razor views
var app = builder.Build();app.MapStaticAssets(); // .NET 9+app.MapControllerRoute( name: "default", pattern: "{controller=Home}/{action=Index}/{id?}") .WithStaticAssets();app.Run();
public class OrdersController : Controller{ // GET /Orders/Details/42 → controller=Orders, action=Details, id=42 public IActionResult Details(int id) { if (id != 42) return NotFound();
var vm = new OrderDetailsViewModel(id, "Contoso", 99.5m); return View(vm); // Views/Orders/Details.cshtml, then Views/Shared/Details.cshtml }}
// ViewModel: only what the page needspublic record OrderDetailsViewModel(int Id, string Customer, decimal Total);@* Views/Orders/Details.cshtml *@@model OrderDetailsViewModel
<h1>Order #@Model.Id</h1><p>@Model.Customer: @Model.Total.ToString("C")</p>- How does
View()differ fromViewResult? —View()is a controller helper that creates aViewResult; rendering happens when the result executes. - Where does MVC look for a view? —
Views/{Controller}/{Action}.cshtml, thenViews/Shared;IViewLocationExpanderchanges the rules. - Why a ViewModel if you already have an entity? — Only the needed fields, no overposting and no database queries during rendering.
- How does
ViewDatadiffer fromViewBag? — It’s the same storage: a string-keyed dictionary and adynamicwrapper over it. - What is Post/Redirect/Get? — After a successful POST, redirect to a GET so that refreshing the page doesn’t resubmit the form.
What is Razor, how is a page put together, and how do Razor Pages differ?
Section titled “What is Razor, how is a page put together, and how do Razor Pages differ?”What it is
Razor is .cshtml markup where HTML is mixed with C#: @Model.Name, @if, @foreach. Everything written out through @ is encoded: a <script> coming from data is shown as text.
How a page is put together
| File or technique | Why |
|---|---|
_Layout.cshtml |
the common shell; @RenderBody() inserts the page |
_ViewStart.cshtml |
code that runs before each view, usually Layout = "_Layout" |
_ViewImports.cshtml |
shared @using, @addTagHelper, @inject |
| partial view | a repeated chunk of markup |
| view component | a chunk with its own logic: a cart, a menu |
Razor Pages
The file Pages/Contact.cshtml with the @page directive answers /Contact by itself. The logic sits next to it in a ContactModel class with OnGet and OnPost methods.
Common mistake
Writing HTML from the database through @Html.Raw “so the tags render”. That’s how someone else’s script gets into the page.
Takeaway: Razor is HTML plus C# with automatic encoding, and layouts, partials and view components remove repetition.
How it works
_ViewStartand_ViewImportsapply to their folder and subfolders._ViewStartruns only for full views, not for partials.- A tag helper is a class that processes an HTML element on the server.
<a asp-action="Details">gets a generated URL,<input asp-for="Email">gets its name, value and validation attributes. - HTML helpers (
@Html.TextBoxFor(m => m.Email)) do the same through C# calls. Third-party suites such as Telerik UI for ASP.NET Core offer both styles.
When to choose what
| Task | Tool |
|---|---|
| Repeat markup with the parent’s data | partial view |
| A block with its own data: cart, menu | view component |
| Change how an HTML element behaves | tag helper |
| A page with a form and a couple of handlers | Razor Pages |
| Many views around one resource, shared filters | MVC controller |
Pitfalls
@Html.Partialis synchronous: analyzer MVC1000 warns about possible deadlocks. Use<partial>orawait Html.PartialAsync.- A view component takes no part in model binding or filters: data arrives only as arguments to
InvokeorInvokeAsync. <vc:cart>needs@addTagHelper *, YourAssemblyin_ViewImports. Without it the tag is silently emitted into the HTML as is.
Rule of thumb
Data access belongs in an action or a view component, not in @{ } blocks of a view.
Takeaway: a partial is for markup, a view component is for markup with logic, a tag helper is for element behavior.
Under the hood
- The Razor generator turns
.cshtmlinto a class: static HTML becomesWriteLiteral,@xexpressions becomeWrite, which encodes values withHtmlEncoder. - The layout runs after the body: the view is rendered first and its sections are captured, then the layout inserts them via
RenderBodyandRenderSectionAsync. - Tag helpers are bound to elements at compile time based on
@addTagHelper. At runtime an instance is created for each target element andProcessAsyncis called. - A view component looks for its view in
Views/{Controller}/Components/{Name}/andViews/Shared/Components/{Name}/.
Cost
- A tag helper means an object and a context per element. In a table with thousands of rows that’s noticeable allocations; a hot fragment is written as plain HTML with
@. - A view component inside a loop means a separate view lookup and render per iteration. Load the data once instead.
<cache>keeps a fragment in process memory, so in a server farm every server has its own copy. For a shared cache there is<distributed-cache>.
Trade-offs
- Razor Pages and MVC run on the same engine. Pages are vertical slices per page; MVC is controllers per resource with shared filters.
- Razor gives no client-side interactivity. You add it with JavaScript or with Blazor Razor components via the
<component>tag helper right inside a view.
How to check in production
Rendering time shows up in MVC logs (“Executed ViewResult … executed in N ms”), and tag helper allocations show up in a profiler (dotnet-trace, PerfView) under load.
Takeaway: Razor compiles to ordinary C#, and its cost is the objects created for each tag helper and view component during rendering.
using Microsoft.AspNetCore.Mvc;using Microsoft.AspNetCore.Mvc.RazorPages;
var builder = WebApplication.CreateBuilder(args);builder.Services.AddControllersWithViews();builder.Services.AddRazorPages(); // MVC and Razor Pages in one app
var app = builder.Build();app.MapControllerRoute("default", "{controller=Home}/{action=Index}/{id?}");app.MapRazorPages(); // Pages/Contact.cshtml → /Contactapp.Run();
// View component: its own logic and view Views/Shared/Components/Cart/Default.cshtmlpublic class CartViewComponent : ViewComponent{ public IViewComponentResult Invoke(int maxItems) => View(new[] { "Keyboard", "Mouse", "Monitor" }.Take(maxItems).ToArray());}
// Razor Page: handlers instead of actions, the model sits next to the markuppublic class ContactModel : PageModel{ [BindProperty] public string Message { get; set; } = "";
public IActionResult OnPost() => string.IsNullOrWhiteSpace(Message) ? Page() : RedirectToPage("Thanks");}@* Views/_ViewImports.cshtml — shared directives for the folder and subfolders *@@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers@addTagHelper *, MyShop
@* Views/_ViewStart.cshtml — runs before every full view *@@{ Layout = "_Layout"; }
@* Views/Shared/_Layout.cshtml *@<main>@RenderBody()</main>@await RenderSectionAsync("Scripts", required: false)
@* Views/Home/Index.cshtml *@@model IReadOnlyList<Product>@foreach (var p in Model){ <partial name="_ProductRow" model="p" />}<vc:cart max-items="2"></vc:cart><a asp-controller="Products" asp-action="Details" asp-route-id="5">Details</a>- How does a partial view differ from a view component? — A partial gets data from its parent; a view component loads data itself and has its own logic.
- Where does
_ViewImports.cshtmlapply? — In its folder and subfolders; files from different levels are merged. - How does Razor protect against XSS? — It encodes everything written through
@;Html.Rawturns encoding off. - Why
asp-append-version="true"? — It adds a file hash to the URL so the browser doesn’t keep a stale cached version. - Can you mix Razor Pages and MVC? — Yes:
AddControllersWithViewsandAddRazorPagesin one app, with shared layouts and tag helpers.
What is SignalR, and which transports does it use?
Section titled “What is SignalR, and which transports does it use?”What it is
SignalR keeps a persistent connection between the browser and the server. The server pushes a message — a new order, a job status, a table row — without reloading the page or polling on a timer.
How to use
- Server: a class deriving from
Hubandapp.MapHub<NotificationsHub>("/hubs/notifications"). - Client: the
@microsoft/signalrpackage,HubConnectionBuilderandconnection.on(...)for incoming messages. connection.invoke("Method", args)calls a public hub method.
Transports, best to fallback
| Transport | How it works |
|---|---|
| WebSockets | one two-way connection |
| Server-Sent Events | an event stream from the server; the client sends separate POSTs |
| Long Polling | a request waits until data appears, then a new one is sent |
Common mistake
Assuming withAutomaticReconnect() retries forever. Without arguments it makes four attempts — after 0, 2, 10 and 30 seconds — and gives up.
Takeaway: SignalR is a persistent connection with calls in both directions, and the transport is chosen automatically.
How it works
- The client sends
POST {hub}/negotiate?negotiateVersion=1. The server replies with aconnectionId, a secretconnectionTokenand the transports with theirTextandBinaryformats. - The client tries the transports in order and connects with
?id={connectionToken}. - The client’s first message is a handshake naming the protocol: JSON (the default) or MessagePack.
- Then calls flow both ways. The server pings every 15 seconds (
KeepAliveInterval) and treats the client as disconnected after 30 seconds of silence (ClientTimeoutInterval).
When to choose what
| JSON | MessagePack | |
|---|---|---|
| Message size | larger | smaller |
| Debugging | readable in DevTools | binary format |
| Packages and transports | built in, every transport | separate packages, no SSE |
Pitfalls
invokewaits for the server’s result and errors;sendwaits only for the message to be sent.- The server rejects client messages larger than 32 KB (
MaximumReceiveMessageSize). Load large data over plain HTTP. - After an automatic reconnect the connection has a new
connectionId, and anything tied to the old one is lost. - Automatic reconnect doesn’t handle a failure of the first
start()— you catch it yourself.
Rule of thumb
SignalR is for notifications and live updates. For heavy data, the client fetches it over plain HTTP after a notification.
Takeaway: negotiate picks the transport and issues the connection token, then method calls flow in both directions.
Under the hood
- WebSockets is full duplex over one TCP connection after a
101 Switching Protocolsresponse. - SSE and Long Polling receive via
EventSourceor a pending GET, and send via separate POSTs to the same endpoint with?id=. - SSE supports only the
Textformat, so with MessagePack the client skips it. - Connection state — buffers, id, user — lives in the memory of the process that handled negotiate.
Cost
- Every connection is a persistent socket plus buffer memory (64 KB each way by default). It’s a server resource even when no messages flow.
- Long Polling costs an HTTP request per batch of data.
- Over HTTP/1.1, SSE takes one of the browser’s six connections per domain.
Trade-offs
- A silent fallback to Long Polling hides infrastructure problems: everything works, but with higher latency and load.
Transports = HttpTransportType.WebSocketson the server andskipNegotiationon the client remove negotiate and fallback. The price is that clients behind “bad” proxies can no longer connect.
How to check in production
- The
signalr.server.active_connectionsmetric (.NET 8+) with thesignalr.transporttag shows the real transport mix;kestrel.upgraded_connectionscounts WebSocket connections. - In the DevTools Network tab you can see
negotiatefollowed by a101response for WebSockets.
Takeaway: the transport determines what a connection costs, so measure the actual transport in production instead of assuming it.
using Microsoft.AspNetCore.SignalR;
var builder = WebApplication.CreateBuilder(args);builder.Services.AddSignalR();
var app = builder.Build();app.MapHub<NotificationsHub>("/hubs/notifications"); // plus /hubs/notifications/negotiateapp.Run();
public class NotificationsHub : Hub{ // client: connection.invoke("Ping", "hello") public Task Ping(string text) => Clients.Caller.SendAsync("Pong", text.ToUpperInvariant());}const connection = new signalR.HubConnectionBuilder() .withUrl("/hubs/notifications") // negotiate, then WebSockets → SSE → Long Polling .withAutomaticReconnect() // retries after 0, 2, 10 and 30 seconds .build();
connection.on("Pong", text => console.log(text)); // HELLO
await connection.start();await connection.invoke("Ping", "hello");- Why is WebSockets better than SSE? — Full duplex over one connection; SSE sends client messages as separate POSTs and carries only text.
- What does negotiate do? — It issues the
connectionId,connectionTokenand transport list; without it you can only connect over WebSockets (skipNegotiation). - What happens if the server restarts? — With
withAutomaticReconnectthe client reconnects with a newconnectionId; without it,onclosefires. - How does
invokediffer fromsend? —invokewaits for the server’s result and errors,sendonly for the message to go out. - How does SignalR notice a dropped connection? — A ping every 15 seconds; the server treats the client as gone after 30 seconds of silence.
What is CSRF, and how does antiforgery work in MVC and Razor Pages?
Section titled “What is CSRF, and how does antiforgery work in MVC and Razor Pages?”What it is
- A user is signed in to
bank.example, and the browser holds a login cookie. - They open
evil.example, which hides a form withaction="https://bank.example/transfer". - A script submits the form. The browser attaches the bank’s cookie, and the server sees a normal request from a signed-in user.
How the token protects
- The server issues a token in a cookie and a matching token in a hidden form field.
- The other site can make the browser send the cookie, but it can’t read the bank’s page to learn the field value.
- A filter compares the pair and returns 400 if the token is missing or doesn’t match.
A <form method="post"> with tag helpers gets the token automatically — you only need to turn on validation with an attribute or a global filter.
Common mistake
Changing data with a GET request. An <img src="/orders/delete/5"> on someone else’s page performs the delete, and GET isn’t checked for a token.
Takeaway: CSRF exploits automatic cookie sending, and the token in the form proves the request came from your own page.
How it works
- When a form is rendered,
IAntiforgerycreates a cookie token (.AspNetCore.Antiforgery.*) and a request token, linked to each other and to the user. - FormTagHelper puts the request token into a
__RequestVerificationTokenfield when the form hasmethod="post"and no hand-writtenaction. - On the request, a filter reads the token from the
RequestVerificationTokenheader or from the form and checks it against the cookie.
When to choose what
| Attribute | What it does |
|---|---|
[ValidateAntiForgeryToken] |
requires a token for every method, even GET |
[AutoValidateAntiforgeryToken] |
requires it for all but GET, HEAD, OPTIONS, TRACE |
[IgnoreAntiforgeryToken] |
turns validation off, overriding higher levels |
The docs recommend AutoValidateAntiforgeryToken globally and IgnoreAntiforgeryToken where needed: a forgotten attribute on a POST won’t leave a hole.
Pitfalls
AddControllers()doesn’t enable antiforgery — you needAddControllersWithViews().- AJAX won’t send the token by itself. Put it in the
RequestVerificationTokenheader or in your own header name viaoptions.HeaderName. - The token is tied to the user, so after sign-in redirect to a fresh page with a fresh token.
- Several tabs signed in as different users aren’t supported.
Rule of thumb
Cookie authentication plus a form or AJAX — you need a token. A bearer token that JavaScript puts in a header — CSRF doesn’t apply.
Takeaway: a global AutoValidateAntiforgeryToken protects every state-changing request by default.
Under the hood
- Tokens are encrypted and signed with Data Protection. The request token references the cookie token and carries a user identifier.
- The cookie token is
HttpOnlyandSameSite=Strictby default. A response with a token getsCache-Control: no-cache, no-storeso that a proxy doesn’t hand it to other users. - Since .NET 8 there is the
UseAntiforgerymiddleware. It validates POST, PUT and PATCH on endpoints with antiforgery metadata and records the result inIAntiforgeryValidationFeature. A Minimal API endpoint with[FromForm]fails with anInvalidOperationExceptionwithout it.
Cost
- A server farm needs a shared Data Protection key ring: Redis, a database or Blob Storage. Otherwise you get 400s after switching nodes or restarting.
- A page with a form is personal, so you can’t cache it whole on a CDN.
Trade-offs
- SameSite=Lax keeps the cookie out of cross-site POSTs, but it doesn’t help against attacks from subdomains of the same site or against GETs with side effects. That’s why tokens stay.
- A bearer token in a header removes CSRF, but a token in
localStorageis readable by any script in an XSS attack. - .NET 11 (shipping November 2026) adds automatic CSRF protection based on the
Sec-Fetch-SiteandOriginheaders. It complements tokens rather than replacing them.
How to check in production
- A wave of 400s on POST after a deploy, with “Antiforgery token validation failed” in the logs, means different Data Protection keys on the nodes.
- Test: a POST without a token must get a 400.
Takeaway: tokens, SameSite and no state changes on GET work together, and in a farm the tokens depend on a shared Data Protection key.
using Microsoft.AspNetCore.Mvc;
var builder = WebApplication.CreateBuilder(args);builder.Services.AddControllersWithViews(options => // every request except GET/HEAD/OPTIONS/TRACE needs a token options.Filters.Add(new AutoValidateAntiforgeryTokenAttribute()));builder.Services.AddAntiforgery(options => options.HeaderName = "X-XSRF-TOKEN");
var app = builder.Build();app.UseAntiforgery(); // for Minimal APIs and Blazor; MVC validates with a filter
// .NET 8+: an endpoint with [FromForm] validates the token automaticallyapp.MapPost("/feedback", ([FromForm] string text) => Results.Ok(text.Length));
app.MapControllerRoute("default", "{controller=Home}/{action=Index}/{id?}");app.Run();
public class ProfileController : Controller{ [HttpPost] // the global filter has already validated the token public IActionResult Save(string displayName) => RedirectToAction("Index");
[HttpPost, IgnoreAntiforgeryToken] // a partner webhook: no cookie, no token public IActionResult Webhook() => Ok();}@inject Microsoft.AspNetCore.Antiforgery.IAntiforgery Antiforgery
@* FormTagHelper adds the hidden __RequestVerificationToken field itself *@<form asp-controller="Profile" asp-action="Save" method="post"> <input name="displayName" /> <button>Save</button></form>
@* fetch: the token goes into a header *@<script> fetch("/Profile/Save", { method: "POST", headers: { "X-XSRF-TOKEN": "@Antiforgery.GetAndStoreTokens(Context).RequestToken" }, body: new FormData(document.forms[0]) });</script>- How does
ValidateAntiForgeryTokendiffer fromAutoValidateAntiforgeryToken? — The latter skips GET, HEAD, OPTIONS and TRACE, so it can be enabled globally. - Does an API with JWT need antiforgery? — No, if the token goes in the
Authorizationheader: the browser doesn’t attach it by itself. - Does SameSite=Lax protect completely? — No: attacks from subdomains and GETs with side effects remain.
- Why do forms return 400 after deploying to a farm? — The nodes have different Data Protection keys; the key ring must be shared.
- How do you pass the token in AJAX? —
IAntiforgery.GetAndStoreTokensand theRequestVerificationTokenheader or your own name fromHeaderName.
How do hubs, groups and sending to specific users work in SignalR?
Section titled “How do hubs, groups and sending to specific users work in SignalR?”What it is
- Hub — a class deriving from
Hub. Clients call its public methods by name. Clients— who to send to:All,Caller,Others,Client(connectionId),Group(name),User(userId).- Group — a named set of connections: a chat room, a specific order.
Context— data about the current connection:ConnectionId,User,UserIdentifier.
How to use
- In a hub method, add the connection to a group:
Groups.AddToGroupAsync(Context.ConnectionId, "order-42"). - Send to everyone in the group:
Clients.Group("order-42").SendAsync("OrderShipped", 42). - Outside the hub, inject
IHubContext<OrdersHub>and use the sameClients.
Common mistake
Keeping data in hub fields. A new instance is created for every call, so the field is always empty.
Takeaway: the hub accepts calls, Clients addresses messages, and IHubContext sends them from anywhere in the app.
How it works
- A hub is created per method call, and scoped dependencies live just as long. By default one client’s methods run one at a time (
MaximumParallelInvocationsPerClient = 1). Clients.User(id)finds all of a user’s connections: tabs, a phone. The id comes fromIUserIdProvider, which by default reads theClaimTypes.NameIdentifierclaim.- Groups live in memory, and a disconnected connection is removed from them automatically.
Authentication
| Client | How credentials travel |
|---|---|
| Browser with cookie sign-in | the cookie is sent automatically |
| Browser with JWT | accessTokenFactory; for WebSockets and SSE — ?access_token= |
| .NET client | the Authorization header |
On the server, the token is read from the query string in JwtBearerEvents.OnMessageReceived, and only for the hub path.
Pitfalls
- Groups don’t survive a reconnect: the client rejoins them in
onreconnected, or the server does inOnConnectedAsync. - A group isn’t a security mechanism. When permissions are revoked, remove the connection from the group explicitly.
- A token in the URL ends up in proxy and server logs, so they need filtering.
IHubContexthas noCallerorContext: outside a hub there is no “caller”.SendAsyncwithoutawaitin a hub method may not finish before the call completes.
Rule of thumb
Address messages through users and groups, not stored connectionId values: those change on every reconnect.
Takeaway: the hub is stateless, addressing goes through groups and users, and the token for browser WebSockets goes in the query string.
Under the hood
DefaultHubLifetimeManager<THub>stores the process’s connections and a dictionary of groups. Sending to a group means iterating its connections and writing to each one.- A message is serialized once per protocol, not once per connection. If a write to a client doesn’t complete within
TransportSendTimeout(10 seconds), the connection is closed. - The
ClaimsPrincipalis cached at connect time, even when Long Polling makes new HTTP requests.CloseOnAuthenticationExpirationcloses the connection when the token expires, and .NET 11 adds refreshing credentials without reconnecting. - Since .NET 7 the server can wait for a client’s answer:
Clients.Client(id).InvokeAsync<T>(...).
Cost
- Broadcasting to 10,000 clients means 10,000 socket writes. A slow client accumulates messages in server memory.
- A hub lives for one call, and so does a
DbContextfrom DI. For long methods,IDbContextFactoryis more convenient.
Trade-offs
Hub<T>checks method names at compile time, but the JS client knows nothing about the contract. Names and arguments are verified by tests.- Cookies are simpler for browsers, bearer tokens for mobile and .NET clients. A token in the query string requires scrubbing logs.
How to check in production
- The
signalr.server.active_connectionsandsignalr.server.connection.durationmetrics (.NET 8+): many short connections point to authentication or proxy problems. EnableDetailedErrorsonly in Development. In production, log the exception on the server and send the client aHubExceptionwith a safe message.
Takeaway: a hub is a thin facade over the connection manager, and groups, users and permissions live no longer than the connection.
using System.Security.Claims;using Microsoft.AspNetCore.Authorization;using Microsoft.AspNetCore.SignalR;
var builder = WebApplication.CreateBuilder(args);builder.Services.AddAuthentication().AddCookie(); // with JWT the token is read from ?access_token=builder.Services.AddAuthorization();builder.Services.AddSignalR();builder.Services.AddSingleton<IUserIdProvider, EmailUserIdProvider>();
var app = builder.Build();app.MapHub<OrdersHub>("/hubs/orders");
// Sending from outside the hub: an endpoint, a controller or a BackgroundServiceapp.MapPost("/orders/{id}/ship", async (int id, string email, IHubContext<OrdersHub, IOrdersClient> hub) =>{ await hub.Clients.User(email).OrderShipped(id); // every tab and device of the user await hub.Clients.Group($"order-{id}").OrderShipped(id); // everyone watching the order return Results.NoContent();});app.Run();
public interface IOrdersClient{ Task OrderShipped(int orderId); // on the client: connection.on("OrderShipped", ...)}
[Authorize]public class OrdersHub : Hub<IOrdersClient>{ public Task Watch(int orderId) => Groups.AddToGroupAsync(Context.ConnectionId, $"order-{orderId}");}
public class EmailUserIdProvider : IUserIdProvider{ public string? GetUserId(HubConnectionContext connection) => connection.User?.FindFirst(ClaimTypes.Email)?.Value;}const connection = new signalR.HubConnectionBuilder() .withUrl("/hubs/orders", { accessTokenFactory: () => getAccessToken() }) .withAutomaticReconnect() .build();
connection.on("OrderShipped", id => showToast(`Order ${id} shipped`));// after a reconnect the id is new and the groups are empty — rejoinconnection.onreconnected(() => connection.invoke("Watch", currentOrderId));
await connection.start();await connection.invoke("Watch", currentOrderId);- Why can’t you keep state in hub fields? — An instance is created for every method call.
- Are groups kept after a reconnect? — No: the connection gets a new id and rejoins its groups.
- How do you send a message from a controller or a
BackgroundService? — ThroughIHubContext<THub>or the strongly typedIHubContext<THub, T>. - Why is a JWT for WebSockets passed in the query string? — The browser WebSocket and EventSource APIs don’t let you set an
Authorizationheader. - What happens if a user’s role is revoked mid-connection? — The hub keeps seeing the old
Context.Useruntil the client reconnects.
How do you scale SignalR across several servers, and what breaks behind a load balancer?
Section titled “How do you scale SignalR across several servers, and what breaks behind a load balancer?”What it is
A SignalR server keeps its clients’ connections in memory. With several servers, server A knows nothing about server B’s clients, and Clients.All on server A reaches only A’s clients.
How it’s solved
| Approach | Idea |
|---|---|
| Redis backplane | servers exchange messages through Redis |
| Azure SignalR Service | clients connect to a cloud service |
| Sticky sessions | the load balancer always sends a client to the same server |
Sticky sessions are needed almost always. You can do without them with Azure SignalR Service, or if clients use WebSockets only and skip negotiate.
Common mistake
Adding a Redis backplane and forgetting sticky sessions. SSE and Long Polling connections break as soon as the load balancer sends the next request to a different server.
Takeaway: multiple servers need a shared message channel and client-to-server affinity.
How it works
- With a Redis backplane, each server subscribes to Redis channels: a global one, its connections’, its users’, and those of groups where it has members.
Clients.Group("g").SendAsync(...)publishes a message to the group’s channel.- Servers subscribed to that channel deliver it to their own connections in the group.
When to choose what
| Redis backplane | Azure SignalR Service | |
|---|---|---|
| Where | your own infrastructure | Azure |
| Sticky sessions | required | not required |
| Who holds connections | your servers | the service |
Pitfalls
- One Redis shared by several apps without a distinct
ChannelPrefixmixes their messages. - If Redis is unavailable, messages are lost. SignalR doesn’t buffer them, though it reconnects to Redis on its own.
- You can’t count a group’s members: each server sees only its part.
- A load balancer without Upgrade support pushes clients to Long Polling, and a short idle timeout drops connections.
- IIS on client editions of Windows allows at most 10 concurrent connections — it’s useless for load tests.
Rule of thumb
Your own infrastructure — a Redis backplane in the same data center plus sticky sessions. Hosting in Azure — Azure SignalR Service.
Takeaway: the backplane solves delivery between servers, sticky sessions solve tying a connection to a process.
Under the hood
- Only the process that issued the
connectionTokenfrom negotiate knows it, and SSE and Long Polling send each message as a separate POST. WebSockets without negotiate is a single connection and needs no affinity. - With Azure SignalR Service, the app’s negotiate returns a redirect to the service URL with an access token. The app server keeps a few persistent connections to the service and sends all messages through them.
- Redis pub/sub doesn’t store messages: what isn’t delivered is never retried.
Cost
- With a backplane each server still holds its own connections, so you scale by client count even when there are few messages.
- Redis Cluster improves availability but lowers throughput: messages go to every node in the cluster.
- Azure SignalR Service is billed in units of 1,000 concurrent connections.
Trade-offs
- A deploy or an IIS app pool recycle drops all of a node’s connections at once. The default
withAutomaticReconnectdelays are identical for every client, so you need anIRetryPolicywith a random delay, or the reconnect wave hits the remaining nodes. - Stateful reconnect (.NET 8+) replays messages after a brief drop, but only to the same server and within the buffer (100,000 bytes by default). It’s negotiated during negotiate, so it isn’t available with
skipNegotiation.
How to check in production
signalr.server.active_connectionsper node: skew means poor balancing; a growinglong_pollingshare insignalr.transportmeans a proxy is blocking WebSockets.signalr.server.connection.durationwith statustimeoutpoints to load balancer idle timeouts.- A load test on two nodes: a group message sent from node A must reach a client on node B.
Takeaway: scaling SignalR means sticky sessions plus a backplane or a service, and reliable delivery is the client’s job.
using StackExchange.Redis;
var builder = WebApplication.CreateBuilder(args);builder.Services.AddSignalR() .AddStackExchangeRedis(builder.Configuration.GetConnectionString("Redis")!, options => options.Configuration.ChannelPrefix = RedisChannel.Literal("shop")); // a distinct prefix per app
// In Azure, instead of Redis — package Microsoft.Azure.SignalR:// builder.Services.AddSignalR().AddAzureSignalR();
var app = builder.Build();app.MapHub<OrdersHub>("/hubs/orders", options => options.AllowStatefulReconnects = true); // .NET 8+: replay after a brief dropapp.Run();
public class OrdersHub : Microsoft.AspNetCore.SignalR.Hub { }// Option 1: negotiate and sticky sessions, stateful reconnect availableconst connection = new signalR.HubConnectionBuilder() .withUrl("/hubs/orders") .withStatefulReconnect() .withAutomaticReconnect({ // random delay: after a deploy, clients don't all come back at once nextRetryDelayInMilliseconds: ctx => ctx.previousRetryCount < 10 ? 1000 + Math.random() * 10000 : null }) .build();
// Option 2: no sticky sessions — WebSockets only, no negotiate// (stateful reconnect is negotiated in negotiate, so it's unavailable here)const wsOnly = new signalR.HubConnectionBuilder() .withUrl("/hubs/orders", { transport: signalR.HttpTransportType.WebSockets, skipNegotiation: true }) .build();- Why are sticky sessions still needed with a Redis backplane? — Only the issuing server knows the
connectionToken, and SSE and Long Polling send many separate requests. - When are sticky sessions not needed? — One server, Azure SignalR Service, or WebSockets only with
skipNegotiation. - What happens to messages if Redis goes down? — They’re lost: the backplane doesn’t buffer, and SignalR reconnects to Redis on its own.
- Why
ChannelPrefix? — It isolates apps that share one Redis. - How do you avoid knocking the cluster over with a reconnect wave after a deploy? — A random delay in
IRetryPolicyand draining nodes from the load balancer gradually.