SignalR — Missing Redis Password Causes Silent Fallback
Connected users saw no errors; messages disappeared across servers.
- SignalR is ASP.NET Core's real-time library enabling server-to-client push via WebSockets with automatic fallback.
- Hub pattern: server-side methods called from clients; client-side methods called from server.
- Transport negotiator picks WebSocket first, then Server-Sent Events, then Long Polling.
- MessagePack serialization cuts payload size ~40% over JSON with minimal CPU cost.
- Production trap: without a backplane (Redis/Azure SignalR), messages only reach clients on the same server instance.
- Biggest mistake: assuming WebSockets always work — fallback to Long Polling spikes server connections and latency.
Imagine a pizza tracker on a delivery app. When your pizza leaves the oven, the screen updates instantly — you didn't refresh the page, the server just told your browser. That's SignalR. It keeps an open conversation channel between your browser and server so the server can shout updates at you the moment something happens, instead of waiting for you to ask. It's the difference between a friend who texts you when your table is ready versus you having to call the restaurant every two minutes.
Most web apps are request-response. Client asks, server answers, connection dies. That works for blog posts. It falls apart for live stock tickers or chat. Polling is the duct-tape fix developers reach for first — wasted CPU, inflated bandwidth, and still a perceptible lag. Real-time user experiences demand a fundamentally different communication model. SignalR gives you a persistent channel between server and client, with automatic fallback when WebSockets aren't available. It's not raw sockets — it's a Hub abstraction that handles serialization, invocation routing, and connection lifecycle so you can focus on business logic. But that abstraction hides sharp edges that will cut you in production.
Here's the thing: every connection costs you memory. A single SignalR connection can easily consume 10 KB of server-side memory. Scale to 10,000 concurrent users, and you're looking at 100 MB just for the socket overhead. That's before you add your business logic state. The trade-off is clear — you get sub-100ms message delivery instead of the 5-second polling interval, but you must plan for resource growth.
What is SignalR for Real-time Apps?
At its simplest, SignalR is a library that lets your server broadcast messages to connected clients instantly. The client doesn't poll — it just listens. Under the hood, it wraps WebSockets when available and degrades gracefully. Think of it as a persistent pipe from server to client. The pipe isn't free — each open connection consumes memory on the server and network resources. But for anything that needs live updates, it's orders of magnitude more efficient than polling.
What makes SignalR different from raw WebSockets is the Hub abstraction. You define methods on the server that clients can call, and the server can call methods on the client. SignalR handles serialization, invocation routing, and connection management. You focus on the business logic, not the socket plumbing.
The Hub pattern also provides lifecycle hooks: OnConnectedAsync and OnDisconnectedAsync. Use them to track connections, assign users to groups, or log connection durations. But be careful — a hub instance is created per method invocation, not per connection. State that needs to survive across calls belongs in Context.Items, not in fields.
A common real-time scenario: stock price feed. Your server receives price updates from an external source and pushes them to all clients subscribed to that symbol. Without SignalR, you'd poll the server every second. With SignalR, the server pushes the new price the moment it arrives. The client updates the UI in under 50ms. That's the difference between a usable trading app and one that loses customers because prices are stale.
Transport Negotiation: How SignalR Picks the Right Pipe
SignalR doesn't assume every client supports WebSockets. When a connection starts, the client sends a negotiate request listing the transports it supports. The server picks the best one available: WebSocket > Server-Sent Events > Long Polling. That fallback chain is what makes SignalR work in corporate proxies or restrictive networks.
You can override the transport with the WithUrl method on the client. Forcing WebSocket-only will cause connection failures on mobile devices or behind older proxies. Always let SignalR negotiate unless you have explicit constraints.
To see which transport a client is using, enable debug logging: Logging:LogLevel:Microsoft.AspNetCore.SignalR=Debug. You'll see lines like "Transport 'WebSockets' selected" or "Falling back to LongPolling".
Also note: the negotiate request itself is an HTTP call. If your load balancer doesn't handle that properly (e.g., strips headers, applies SSL termination incorrectly), the negotiation can fail before WebSocket upgrade even starts.
A real example: a client behind a strict corporate proxy saw all connections fall back to Long Polling. The proxy was stripping the Upgrade header. The fix was to configure the proxy to allow WebSocket connections for the specific subdomain. After that, 100% of clients used WebSockets and server CPU dropped by 60%.
Hub Internals and Message Serialization
Hubs are the central class that receive method calls from clients and invoke client-side methods. Each hub method runs asynchronously. The hub's lifetime is per-client-invocation — you cannot store state in hub fields across calls. Use Context.Items for per-connection state.
SignalR serializes messages using JSON by default. Switching to MessagePack can reduce payload size by ~40% and improve throughput because binary serialization is faster to parse. Enable it on both client and server.
Example of enabling MessagePack on server: services.AddSignalR().AddMessagePackProtocol(); On client: .AddMessagePackProtocol() in the HubConnectionBuilder.
Another important internal: the hub dispatcher uses reflection to find methods. If you overload a method name, only one is chosen – avoid overloaded hub methods. Also, method parameters are deserialized from the message; if they don't match, you get a silent invocation skip.
Hub methods must return Task (or Task<T>). async void will cause unobserved exceptions that crash the SignalR pipeline. Always return Task, even if the method is void-like.
Performance tip: if you're sending large payloads frequently, consider compressing the data before sending. SignalR doesn't compress message bodies out of the box. You can implement a custom HubFilter that compresses outgoing messages and decompresses incoming ones. This can reduce bandwidth by 80% for large JSON payloads, but adds CPU overhead.
- A new hub instance is created per-client invocation, not per connection.
- You cannot store state in fields across multiple calls — use Context.Items for per-connection data.
- Hub methods must return Task or void (async void will crash the SignalR pipeline).
- Dependency injection is supported via constructor injection for services needed in every call.
Connection Lifecycle: Groups, Reconnection, and State
SignalR groups allow you to send messages to a subset of connections. Groups are server-side only and exist as long as at least one connection is in the group. They are tied to the server instance — a group created on server A cannot be used from server B unless you use a backplane.
Reconnection is built-in: the client automatically retries after disconnection. The WithAutomaticReconnect method lets you configure retry intervals and behaviors. However, any state in Context.Items is lost on reconnect because it's a completely new connection.
When a client reconnects, it gets a brand new ConnectionId, and all prior group memberships are gone. Your code must handle re-joining groups. Typically you save the list of groups the user should be in on the client side (localStorage) and re-join them after reconnect. On the server, OnConnectedAsync is the right place to validate and re-add group memberships based on authentication claims.
Another edge: if you use Groups.AddToGroupAsync and then the connection drops before the group add is acknowledged, the add may not have taken effect. Use ack-based patterns if group membership is critical.
In a multi-server setup without a backplane, groups are per-server — a group on server A has no meaning on server B. With a backplane, groups become global.
One more thing: the maximum number of groups per connection is unlimited, but listing all groups for a connection is not possible via the public API. If you need this, maintain your own mapping in a concurrent dictionary or Redis.
ConnectionId. Any group memberships from the old connection are gone. You must re-add the client to groups after reconnect, typically in OnConnectedAsync or from the client rejoining rooms.OnConnectedAsync based on user claims.Scaling SignalR with a Backplane: Redis and Azure SignalR Service
Out of the box, SignalR sends messages only to clients connected to the same server instance. To scale out across multiple servers, you need a backplane — a component that relays messages between servers. The two most common options are Redis backplane and Azure SignalR Service.
Redis backplane uses pub/sub channels; each server subscribes to all channels and publishes messages for other servers to receive. Azure SignalR Service acts as a managed proxy — your servers push messages to the service, and the service delivers them directly to clients, removing the need for sticky sessions.
When using Redis, pay attention to connection string syntax. The format is: redis://:password@host:port. If you omit the colon before the password, Redis will try to authenticate with an empty password. SignalR logs a single INFO-level warning on failure, which is easy to miss. Always verify the backplane is working by checking startup logs or using a Redis monitor.
Azure SignalR Service handles scaling more gracefully — you pay per unit. But it introduces additional latency (the message goes from server to Azure to client). For most apps this is negligible.
Important: if you use Redis backplane, you don't need sticky sessions because messages are relayed. However, if you don't use a backplane, you MUST use sticky sessions to ensure a client's subsequent requests hit the same server where their connection lives.
A hidden gotcha: backplane message ordering is best-effort in Redis. If your app requires strict per-session message order, you need a deterministic routing strategy or use Azure SignalR Service which guarantees per-hub ordering.
Production Gotchas: Connection Drops, Memory Leaks, and Monitoring
Even with a correct setup, SignalR can silently fail. Common issues include: idle timeouts too aggressive, connection pooling exhaustion on the Redis backplane, and memory leaks from failing to dispose of clients.
Monitor these metrics: active connections (per server), messages per second, transport type distribution, and connection duration. Use Microsoft.AspNetCore.SignalR counters via dotnet-counters or Application Insights.
Another gotcha: SignalR's built-in reconnection is good but doesn't restore your user's context. If you rely on Context.Items for user identity, that is lost on reconnect. Use authentication tokens or claims to repopulate context after reconnect.
Memory leaks: each hub connection allocates memory. If clients connect and never disconnect cleanly (e.g., mobile app killed without closing WebSocket), your server can accumulate zombie connections. Set aggressive idle timeouts on both sides to clean them up.
Also watch out for the 'too many connections' issue on the Redis side. Each server instance opens a connection to Redis; if you have many instances, Redis may reach its connection limit. Use connection multiplexing or increase Redis maxclients.
One more trap: TLS renegotiation. If you're using HTTPS with client certificates, the handshake overhead per WebSocket upgrade can cause CPU spikes. Consider using a dedicated SignalR endpoint on a separate port with TLS termination at the load balancer.
KeepAliveInterval is 15 seconds, ClientTimeoutInterval is 30 seconds (2 missed keep-alives). For long-lived connections, reduce keep-alive interval or increase timeout. Behind a load balancer, match these to the balancer's idle timeout.KeepAliveFrequency to 60 seconds and ClientTimeoutInterval to 120 seconds on the server, and configure the load balancer's idle timeout to 5 minutes (or use TCP keep-alive).The Redis Backplane Config That Took Down Our Chat Service at 50k Users
docker compose logs that no backplane warnings appear. After restart, messages propagated across servers immediately.- SignalR's Redis backplane logs a single warning at INFO level when it can't connect — assume nothing; explicitly verify with a health check.
- Add a startup validation that writes a test message through the backplane and confirms it arrives on a secondary server before declaring the deployment healthy.
- Never rely on 'no errors' as proof of correct configuration. SignalR degrades gracefully and quietly.
docker compose logs signalr and grep for 'MessageBus'. If no Redis/ServiceBus backplane is listed, messages are per-server only. Enable SignalR server logs with Logging:LogLevel:Microsoft.AspNetCore.SignalR=Debug.new WebSocket('ws://')). Check reverse proxy (nginx, IIS) for missing WebSocket upgrade headers. Add --proxy-http-version 1.1 --proxy-set-header Upgrade $http_upgrade --proxy-set-header Connection 'upgrade' in nginx.context.Items may overflow. Check hub method for async void – use async Task always. If using Azure SignalR Service, verify connection limit per unit isn't exceeded.KeepAliveInterval default is 15s, ClientTimeoutInterval default is 30s. If clients are behind a proxy that has shorter timeouts, increase these intervals. Also verify WebSocket Ping frequency matches proxy expectations.Key takeaways
Common mistakes to avoid
6 patternsMemorising syntax before understanding the concept
Skipping practice and only reading theory
Using async void in hub methods
Forgetting to configure a backplane in multi-server deployments
Not handling connection reconnect and group re-joining
Missing CORS configuration for WebSocket connections
AllowCredentials() and AllowAnyHeader(). Ensure the allowed origin matches the client's origin exactly.Interview Questions on This Topic
Explain the SignalR transport negotiation process. What is the fallback chain and why does it matter in production?
Frequently Asked Questions
That's ASP.NET. Mark it forged?
7 min read · try the examples if you haven't