Skip to main content

Command Palette

Search for a command to run...

SignalR Falls Back to Long Polling in ASP.NET Core: Causes and Fix

Updated
11 min readView as Markdown
SignalR Falls Back to Long Polling in ASP.NET Core: Causes and Fix

Your SignalR hub works, messages arrive, and everything looks healthy in development. Then you ship to production behind a reverse proxy, open the browser Network tab, and see it: every message is riding a slow chain of HTTP requests instead of a single WebSocket. SignalR is using long polling instead of WebSockets, and latency has quietly doubled. This is one of the most common real-time issues I get asked about, and the frustrating part is that nothing "errors" - the app just silently drops to the slowest transport it has.

In production I've seen this exact symptom take down a live dashboard's responsiveness without a single exception in the logs. The reason is by design: SignalR is built to degrade gracefully rather than fail, so when WebSockets are blocked it falls back to Server-Sent Events, and then to long polling, without telling you. That safety net is great for connectivity and terrible for anyone who assumes WebSockets are in use. If you want the complete diagnostic playbook alongside this article - the proxy configs, a scale-out backplane wired end to end, and the client and server code that pins the right transport - I keep the full working version on Patreon, ready to clone and run.

This post walks through what the fallback actually is, why it happens, how to diagnose which transport you are really on, and how to fix it for good. If you are still deciding whether SignalR is even the right tool versus raw WebSockets or SSE, start with our breakdown of Server-Sent Events vs SignalR vs WebSockets first, then come back here to get the transport working correctly.

What Transports Does ASP.NET Core SignalR Actually Support?

ASP.NET Core SignalR supports exactly three transports, and it picks the best available one automatically during connection negotiation:

  1. WebSockets - a single, full-duplex TCP connection. This is the preferred transport and the one you almost always want. Lowest latency, lowest overhead.

  2. Server-Sent Events (SSE) - a one-way server-to-client stream over HTTP, with client-to-server messages sent as separate requests. Used when WebSockets are unavailable.

  3. Long Polling - the client repeatedly issues HTTP requests that the server holds open until it has data. Maximally compatible, works almost everywhere, and is the slowest and chattiest option.

One important clarification, because the search results are full of it: Forever Frame is not an ASP.NET Core SignalR transport. Forever Frame belonged to the legacy ASP.NET SignalR (the pre-Core library). If you are on ASP.NET Core - .NET 8, .NET 10, or anything modern - your transport list is only those three above. Any tutorial listing four transports is describing the old framework.

By default all three transports are enabled, and SignalR negotiates down the list until one connects, as described in the official ASP.NET Core SignalR configuration docs. That is why a broken WebSocket setup never throws: long polling is always there to catch the fall.

What Is the SignalR Transport Fallback Order?

SignalR negotiates transports in a fixed priority: WebSockets first, then Server-Sent Events, then Long Polling. The client and server exchange a negotiate request, agree on the transports both support, and the client attempts them in that order. The first one to establish a connection wins. If WebSockets are silently blocked somewhere in your infrastructure, the client tries SSE, and if the response stream is buffered or broken by a proxy, it lands on long polling. Everything still "works," just slowly.

Why Does SignalR Use Long Polling Instead of WebSockets?

The fallback is a symptom, not the disease. Something between your client and your Kestrel server is preventing the WebSocket upgrade. In real deployments, the cause is almost always one of these:

  • A reverse proxy that does not forward the WebSocket upgrade. nginx, Apache, HAProxy, and hardware load balancers do not proxy the HTTP Upgrade handshake unless you explicitly configure them to. This is the single most common cause I see.

  • Azure App Service with WebSockets turned off. The platform ships with the WebSockets toggle disabled by default. Until you flip it on, every connection degrades.

  • IIS without the WebSocket Protocol feature installed. IIS silently rejects the upgrade if the Windows feature is missing, and SignalR falls back.

  • Scale-out without sticky sessions. With more than one server and no backplane, the negotiate request and the follow-up connection can land on different instances. SSE and long polling both break without session affinity.

  • A buffering proxy that kills SSE. Some proxies buffer responses, which stalls Server-Sent Events and pushes the client one more rung down to long polling.

  • CORS misconfiguration when the client and hub are on different origins, causing the WebSocket handshake to fail before it starts.

The common thread: SignalR itself is fine. The transport is being blocked in transit, and the graceful fallback hides it.

How Do You Diagnose Which Transport SignalR Is Using?

Before changing anything, confirm what transport you are actually on. Guessing wastes hours.

Browser Network tab. Open DevTools, filter by "WS," and reload. A healthy WebSocket connection shows a request with status 101 Switching Protocols that stays open. If instead you see a stream of repeating requests to your hub path ending in ?id=..., you are on long polling.

Server-side logging. Turn up SignalR's log level and watch the negotiated transport during connection. Add this to appsettings.Development.json:

{
  "Logging": {
    "LogLevel": {
      "Microsoft.AspNetCore.Http.Connections": "Debug",
      "Microsoft.AspNetCore.SignalR": "Debug"
    }
  }
}

The connection logs will name the transport each client settles on, so you can confirm the fallback rather than infer it.

Client-side. In the browser console, enable verbose SignalR logging with configureLogging(signalR.LogLevel.Debug) on your connection builder. It prints each transport attempt and why it was skipped - often the fastest way to see the WebSocket attempt fail and the reason.

The Fix: Getting WebSockets Working End to End

There is no single switch. You fix the layer that is blocking the upgrade, then optionally pin the transport so a regression is loud instead of silent.

Step 1: Configure Your Reverse Proxy to Forward the Upgrade

If you run behind nginx, the WebSocket upgrade headers must be forwarded explicitly. This is the fix for the majority of cases:

location /hubs/ {
    proxy_pass         http://backend;
    proxy_http_version 1.1;
    proxy_set_header   Upgrade $http_upgrade;
    proxy_set_header   Connection "upgrade";
    proxy_set_header   Host $host;
    proxy_read_timeout 100s;
}

The proxy_http_version 1.1 line and the Upgrade/Connection headers are mandatory - without them nginx strips the handshake and SignalR degrades. For Azure App Service, enable "Web sockets" under Configuration → General settings. For IIS, install the WebSocket Protocol feature via Windows features or the Server Manager role, then recycle the app pool.

Step 2: Confirm the Server Allows WebSockets

SignalR handles the WebSocket handshake itself, so you do not need app.UseWebSockets() just for SignalR. But you can be explicit about which transports a hub accepts. To keep a hub honest and reject the slow fallbacks entirely, restrict it server-side (available since ASP.NET Core 3.0, current in .NET 10):

app.MapHub<ChatHub>("/hubs/chat", options =>
{
    options.Transports = HttpTransportType.WebSockets;
});

Use this deliberately. Restricting to WebSockets means clients in genuinely hostile networks cannot connect at all, so only do it when you control the environment.

Step 3: Pin the Client to WebSockets (Optional but Powerful)

Once the infrastructure supports WebSockets, you can force the client to use them and skip negotiation entirely. Skipping negotiation removes a round trip and makes a broken WebSocket path fail loudly instead of degrading:

const connection = new signalR.HubConnectionBuilder()
    .withUrl("/hubs/chat", {
        skipNegotiation: true,
        transport: signalR.HttpTransportType.WebSockets
    })
    .build();

Two caveats worth knowing before you ship this. First, skipNegotiation: true only works with the WebSockets transport - it cannot be combined with SSE or long polling. Second, if you use Azure SignalR Service, do not set skipNegotiation on the client: the service relies on the negotiate step to redirect clients and to issue access tokens, and skipping it forces the client to build tokens itself, which is a security risk you do not want.

How Do You Prevent SignalR From Falling Back After Scaling Out?

The fallback often reappears the moment you add a second server instance. Once you scale out, the negotiate request and the actual connection can hit different machines, and any non-WebSocket transport breaks without session affinity.

Microsoft's SignalR production hosting and scaling guidance is explicit about this, and you have two clean options:

  • Add a backplane. Use the Redis backplane or Azure SignalR Service so messages fan out across all instances regardless of which server a client is connected to. Azure SignalR removes the sticky-session requirement entirely because clients are redirected to the service on connect.

  • Enable sticky sessions. If you self-host without Azure SignalR, turn on session affinity (ARR affinity on Azure, ip_hash or a sticky cookie in nginx) so a client stays pinned to one instance. The one exception: sticky sessions are not required if every client uses WebSockets only and has skipNegotiation enabled, because there is no separate negotiate hop to strand.

For anything beyond a single node, treat the backplane decision as part of your real-time architecture from day one, not a patch you bolt on after the incident. The same production-hardening mindset applies to timeouts and retries across your services, which we cover in gRPC Client Resilience in ASP.NET Core.

Frequently Asked Questions

Why is my SignalR connection using long polling instead of WebSockets?

Because something between the browser and Kestrel is blocking the WebSocket upgrade and SignalR is gracefully degrading. The usual culprits are a reverse proxy that does not forward the Upgrade and Connection headers, WebSockets disabled on Azure App Service, the IIS WebSocket feature not being installed, or scale-out without sticky sessions or a backplane. Diagnose the real transport in the browser Network tab first, then fix the blocking layer.

Is long polling in SignalR a problem, or is it fine?

It is functionally fine but performance-poor. Long polling works in almost any network, which is exactly why it is the last-resort fallback. The cost is higher latency, more HTTP overhead, and more server connections held open. For a low-traffic internal tool it may be acceptable; for a high-traffic or latency-sensitive app, being stuck on long polling instead of WebSockets is a real regression worth fixing.

How do I force SignalR to use WebSockets only?

Restrict transports on both ends. Server-side, set options.Transports = HttpTransportType.WebSockets when mapping the hub. Client-side, pass transport: HttpTransportType.WebSockets with skipNegotiation: true in withUrl. Only do this once your proxies and hosting actually support WebSockets, because clients in restrictive networks will otherwise fail to connect entirely.

Does ASP.NET Core SignalR support Forever Frame?

No. ASP.NET Core SignalR has three transports only: WebSockets, Server-Sent Events, and Long Polling. Forever Frame was a transport in the legacy ASP.NET SignalR library and was not carried over to ASP.NET Core. If you see it referenced for a modern .NET app, the source is describing the old framework.

Do I need app.UseWebSockets() for SignalR to use WebSockets?

No. SignalR manages the WebSocket handshake internally through its connection middleware, so you do not add app.UseWebSockets() just to enable the SignalR WebSockets transport. You would only call UseWebSockets() if you are also handling raw, non-SignalR WebSocket endpoints in the same app.

Why does SignalR fall back to long polling only after I scale to multiple servers?

Because non-WebSocket transports and the negotiate step assume the client keeps hitting the same instance. With multiple servers and no affinity, requests scatter across machines and the connection cannot be maintained, so it degrades or fails. Add a Redis backplane or Azure SignalR Service, or enable sticky sessions, to keep real-time connections stable across a scaled-out deployment.


About the Author

I'm Celin Daniel, Co-founder of Coding Droplets. I've been building .NET and ASP.NET Core systems in production for 13+ years - APIs, distributed backends, enterprise platforms. Everything I write here comes from real shipping experience: patterns that held up, trade-offs that bit us, and lessons learned the hard way.

More from this blog

C

Coding Droplets

285 posts

Coding Droplets is your go-to resource for .NET and ASP.NET Core development. Whether you're just starting out or building production systems, you'll find practical guides, real-world patterns, and clear explanations that actually make sense.

From beginner-friendly tutorials to advanced architecture decisions. We publish fresh .NET content every day to help you grow at every stage of your career.