Azure SignalR Service
Key Points
- Managed SignalR backplane: offloads WebSocket / long-polling connection management from your app servers. Your code still uses
Hub/IHubContext; the service owns the persistent connections. - Two operational modes: Default (legacy — service is a proxy, app still hosts connections) and Service (recommended — clients connect to the service, server pushes via service). Plus Serverless mode for Azure Functions.
- Pricing by units: 1 Standard unit = 1,000 concurrent connections + ~20K msg/sec. Free tier (20 connections) for dev only.
- Eliminates sticky sessions and the Redis backplane at the app tier — flat HTTP server farm with no connection affinity.
- Authentication: JWT-based negotiation; server-issued claims propagate to the hub.
- Idiomatic uses: chat, live dashboards, notifications, collaborative editing, real-time price/order feeds, IoT command channels.
- Pricing pitfall: idle connections still count. Long-lived browser tabs = sustained unit billing.
Concepts (deep dive)
The problem self-hosted SignalR has at scale
Self-hosted SignalR (no service):
Client A ──WS──▶ App-1 ───┐
Client B ──WS──▶ App-2 ───┤ ← sticky load balancer
Client C ──WS──▶ App-3 ───┘
│
Redis backplane
(pub/sub fans out
messages between app instances)
Pain points: - Sticky sessions required (long-polling fallback can't reconnect to a different node). - Redis backplane has limits (~few thousand msg/sec sustained before tail latency suffers). - App instance restart = thousands of clients reconnect at once (thundering herd). - WebSocket count caps app server scale (each connection = one socket + small heap).
What Azure SignalR Service does
Azure SignalR Service:
Client A ──WS──▶ ┌──────────────────────────────┐ ◀── App-1
Client B ──WS──▶ │ Azure SignalR Service │ ◀── App-2
Client C ──WS──▶ │ (managed connection pool) │ ◀── App-3
└──────────────────────────────┘
▲
Server-to-Service WS (few)
- Clients open WebSockets to the service, not your app.
- App servers maintain a small fixed number of WebSockets to the service (server connections).
- Service handles connection state, fan-out, reconnection.
- No sticky sessions, no Redis backplane.
Two server modes
| Mode | Who owns client connections | When to use |
|---|---|---|
| Default mode | App server owns; service forwards | Legacy SignalR apps with custom transport logic. Rarely correct. |
| Service mode (recommended) | Service owns; app pushes via service | All new apps. |
| Serverless mode | No app server; clients ↔ service; Functions emit | Functions-based, sporadic broadcast. |
// Service mode (default in modern AddAzureSignalR)
builder.Services.AddSignalR().AddAzureSignalR(o =>
{
o.ConnectionString = builder.Configuration["AzureSignalR:ConnectionString"];
o.ServerStickyMode = ServerStickyMode.Disabled; // service handles routing
});
Pricing model
Standard tier:
1 unit = 1,000 concurrent connections
+ ~20,000 messages/second
+ 24/7 uptime
Cost ≈ ~$50/unit/month (region-dependent)
Up to 100 units → 100K connections per resource
Free tier:
20 connections, 20K msg/day, 1 unit cap. Dev only.
Premium tier (newer):
Higher SLA, autoscale, larger units.
⚠️ Idle connections count. A user with three open tabs across all your dashboards = 3 connections. Plan capacity at peak concurrency, not active users.
Serverless mode — Functions-friendly
Negotiation flow:
- Client GET
/api/negotiate→ Function returns service URL + JWT. - Client opens WebSocket directly to service.
- Function emits messages via output binding to push to clients.
[Function("negotiate")]
public IActionResult Negotiate(
[HttpTrigger("get")] HttpRequest req,
[SignalRConnectionInfoInput(HubName = "notifications")] string connectionInfo)
=> new OkObjectResult(connectionInfo);
[Function("Broadcast")]
[SignalROutput(HubName = "notifications")]
public SignalRMessageAction Broadcast([QueueTrigger("orders")] OrderMsg msg) =>
new SignalRMessageAction("orderUpdated") { Arguments = new object[] { msg } };
Use for: notifications, IoT command fan-out, low-volume broadcast where you don't want to run an app server.
REST API — emit from anything
You can push messages without an app server or Function — straight HTTP:
POST https://<svc>.service.signalr.net/api/v1/hubs/notifications
Authorization: Bearer <jwt-signed-with-access-key>
{ "target": "newOrder", "arguments": [{ "id": 42, "total": 100 }] }
Use for: CLI tools, batch jobs, GitHub Actions notifying live dashboards.
Authentication & authorization
Client App Server Azure SignalR
│ │ │
│ POST /negotiate │ │
├──────────────────────▶│ │
│ │ Sign JWT (claims, hub) │
│ ◀─── { url, token } ──│ │
│ │
│ Connect WS with JWT │
├────────────────────────────────────────────────▶│
│ │ verify JWT
│ ◀──── connected ────────────────────────────────│
- Negotiate endpoint runs in your app — apply
[Authorize]here. ASP.NET issues a JWT signed with the SignalR access key. - Server claims propagate;
Context.Userin hubs sees them. - Use Microsoft Entra ID for service-to-service auth (managed identity) instead of access keys where possible.
Hub example
// Program.cs
builder.Services.AddSignalR().AddAzureSignalR();
app.MapHub<NotificationsHub>("/hubs/notifications");
// Hub
[Authorize]
public class NotificationsHub : Hub
{
public Task SubscribeOrders(string tenantId) =>
Groups.AddToGroupAsync(Context.ConnectionId, $"tenant:{tenantId}");
}
// Push from anywhere in the app
public class OrderService(IHubContext<NotificationsHub> hub)
{
public Task Publish(Order o) =>
hub.Clients.Group($"tenant:{o.TenantId}")
.SendAsync("orderUpdated", o);
}
Client examples
JavaScript:
const conn = new signalR.HubConnectionBuilder()
.withUrl("/hubs/notifications", { accessTokenFactory: () => getToken() })
.withAutomaticReconnect()
.build();
conn.on("orderUpdated", (order) => render(order));
await conn.start();
await conn.invoke("SubscribeOrders", "tenant-42");
.NET:
var conn = new HubConnectionBuilder()
.WithUrl("https://app/hubs/notifications", o => o.AccessTokenProvider = GetTokenAsync)
.WithAutomaticReconnect()
.Build();
conn.On<Order>("orderUpdated", o => Process(o));
await conn.StartAsync();
Backpressure & quotas
| Limit | Value | Notes |
|---|---|---|
| Messages/sec/unit | ~20K | Standard tier |
| Message size | 1 MB | Hub method max |
| Server connections | 5 per app instance default | Tune for high throughput |
| Group ops/sec | ~few thousand | Group-add/remove |
If you exceed message rate, the service drops with throttling errors. Scale units up; broadcast at lower frequency; aggregate on the server.
Observability
- App Insights integration:
dependenciestable shows hub invocations. - Service-side metrics: connection count, message count, server connections health.
- Diagnostic logs to Log Analytics: connectivity events, throttle events.
How it works under the hood
┌─────────────────────────────────────────────────────────┐
│ Azure SignalR Service │
│ │
│ Connection routing layer │
│ ┌─────────────────────────────────────────────────┐ │
│ │ Holds N client WebSockets (1 per browser tab) │ │
│ │ Holds M server WebSockets (M ≪ N) │ │
│ │ Routes messages by group / user / connection │ │
│ └─────────────────────────────────────────────────┘ │
└────┬───────────────────────────────────────────┬────────┘
│ N client WebSockets │ M server WebSockets
▼ ▼
[Browsers / mobile / IoT] [App-1, App-2, App-3]
(stateless;
no sticky sessions)
When IHubContext.Clients.Group("X").SendAsync(...): 1. App emits the message over a server WebSocket. 2. Service looks up group membership in its routing table. 3. Service fans out to relevant client WebSockets.
App server doesn't track which connection is on which instance — service owns that.
Code: correct vs wrong
❌ Wrong: connection string in appsettings, checked in
✅ Correct: Key Vault + Managed Identity
builder.Configuration.AddAzureKeyVault(new Uri(vaultUri), new DefaultAzureCredential());
builder.Services.AddSignalR().AddAzureSignalR(o =>
{
o.ConnectionString = builder.Configuration["SignalR-ConnectionString"];
});
Or — better — Managed Identity directly:
builder.Services.AddSignalR().AddAzureSignalR(o =>
o.Endpoints = new[] { new ServiceEndpoint(new Uri("https://svc.service.signalr.net"), new ManagedIdentityCredential()) });
❌ Wrong: hub method does heavy work synchronously
public async Task SubmitOrder(Order o)
{
await _db.SaveAsync(o); // 200ms
await _payments.ChargeAsync(o); // 800ms
await Clients.All.SendAsync("done"); // blocks the hub during all of it
}
✅ Correct: enqueue and ack
public async Task SubmitOrder(Order o)
{
await _queue.SendAsync(o);
await Clients.Caller.SendAsync("queued", o.Id);
// Worker pushes "done" via IHubContext when complete
}
❌ Wrong: per-message group churn
foreach (var item in items)
{
await Groups.AddToGroupAsync(Context.ConnectionId, $"item:{item.Id}");
}
100K group memberships per connection → routing-table blowup, slow ops.
✅ Correct: hierarchical groups
await Groups.AddToGroupAsync(Context.ConnectionId, $"tenant:{tenantId}");
// Server-side filters which items to send within the tenant group
Design patterns for this topic
Pattern 1 — "Service mode + IHubContext fan-out"
- Intent: stateless app servers; service owns connections.
- How:
AddAzureSignalR(); push viaIHubContext.Clients.Group(...).
Pattern 2 — "Serverless mode for sporadic broadcast"
- Intent: no app server; Functions emit via output binding.
- How: SignalR service in serverless mode + Functions trigger.
Pattern 3 — "REST API from CLI / batch"
- Intent: push live updates from non-app sources.
- How: HTTP POST with signed JWT to service REST endpoint.
Pattern 4 — "Group per tenant, not per item"
- Intent: keep group cardinality low.
- How: server filters within tenant group; clients subscribe by tenant only.
Pattern 5 — "Negotiate as auth gate"
- Intent: enforce identity before WS.
- How:
[Authorize]on negotiate; claims flow into JWT.
Pros & cons / trade-offs
| Aspect | Pros | Cons |
|---|---|---|
| Managed service | No sticky sessions, no Redis backplane | Per-unit cost |
| Service mode | Stateless app | Slightly different push pattern (IHubContext only) |
| Serverless mode | No app server at all | Limited to broadcast/fan-out semantics |
| REST API | Push from anywhere | JWT signing required |
| Pricing by units | Predictable | Idle connections still billed |
| Free tier | Free for dev | 20 connections only |
vs alternatives:
| Alternative | When over Azure SignalR |
|---|---|
| Self-host SignalR + Redis | Already running Redis + small connection counts; cost-sensitive |
| Pusher / Ably | Multi-cloud, simpler API, channels-first model |
| AWS API Gateway WebSockets | AWS-native stack |
| WebPubSub (Azure) | Protocol-agnostic (not just SignalR) — pub/sub over WebSockets, broader client support |
When to use / when to avoid
- Use for chat, live dashboards, notifications, collab editing, IoT command channels.
- Use Service mode for new apps. Always.
- Use Serverless mode when there's no app server (Functions only).
- Avoid for request/response (use HTTP).
- Avoid for durable messaging (use Service Bus).
- Avoid Default mode unless porting legacy code.
- Avoid at very low concurrency where self-host is fine and free.
Interview Q&A
Q1. Why use Azure SignalR Service over self-hosted SignalR? Eliminates sticky sessions and Redis backplane; offloads connection management; scales beyond what app servers can hold sockets for.
Q2. Default vs Service mode? Default: app still owns connections (legacy). Service: clients connect to the service, app pushes via service (modern, stateless apps).
Q3. Pricing model? Per unit. 1 Standard unit = 1,000 concurrent connections + 20K msg/s. Idle connections still count.
Q4. Serverless mode? No app server. Functions emit via output binding; clients connect directly to the service. For Functions-only architectures.
Q5. How does authentication work? Client hits /negotiate on app; app applies [Authorize] and issues a signed JWT; client uses JWT to connect to the service.
Q6. Does it eliminate the Redis backplane? Yes — fan-out happens in the service. Apps no longer need a backplane.
Q7. What about message persistence? None. SignalR is for live messages. Use Service Bus / Event Hubs for durable.
Q8. How do groups work? Service maintains group membership. Groups.AddToGroupAsync routes a connection to a group. Avoid per-item groups; use hierarchical (per-tenant, per-room).
Q9. Pricing pitfall? Idle connections count. Plan capacity at peak concurrent connections, not active users.
Q10. REST API use case? Emit messages from non-app sources: CLI scripts, GitHub Actions, batch jobs, anything that can sign a JWT.
Q11. Compare with WebPubSub? WebPubSub is the protocol-agnostic sibling — pure WebSocket pub/sub, not SignalR-specific. Use SignalR Service for ASP.NET Core SignalR apps; WebPubSub for cross-language WebSocket needs.
Q12. How to push from a worker process? Inject IHubContext<THub>; Clients.Group(...).SendAsync(...). Service routes to the right connections.
Gotchas / common mistakes
- ⚠️ Default mode in new apps — wrong choice; use Service mode.
- ⚠️ Heavy work in hub methods — blocks; offload to queues/workers.
- ⚠️ Per-item groups — group-table bloat.
- ⚠️ Connection string in source — use Key Vault / Managed Identity.
- ⚠️ Forgetting
[Authorize]on negotiate — anonymous connections succeed. - ⚠️ Capacity planning by active users — must use peak concurrent connections.
- ⚠️ Treating SignalR as durable messaging — it isn't.
- ⚠️ Heavy message rate per unit — > 20K msg/s/unit throttles; aggregate on server.