Skip to content

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

Client ──WS──▶ Azure SignalR ──▶ (no app server!)
                  │ REST / output binding
            Azure Functions

Negotiation flow:

  1. Client GET /api/negotiate → Function returns service URL + JWT.
  2. Client opens WebSocket directly to service.
  3. 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.User in 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: dependencies table 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

{ "AzureSignalR": { "ConnectionString": "Endpoint=...;AccessKey=AAAA..." } }

✅ 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 via IHubContext.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.

Further reading