Updated on

A SignalR client does not reconnect on its own. Calling withAutomaticReconnect() on HubConnectionBuilder opts the connection in, and the client then retries after 0, 2, 10 and 30 seconds before giving up.

Those four attempts are the whole default. Pass an array of millisecond delays to change the timing and the count, or an object implementing IRetryPolicy to decide each delay at the moment it is needed.

The completed source code of the SignalR Angular Tutorial: Real-Time Charts in ASP.NET Core article is a good starting point for this article and we’ll make our changes on top of that.

We highly recommend reading that article first and trying out the project we created there. That will help you understand the concept of ASP.NET Core SignalR and will make it easy to follow along with this article.

Furthermore, we have learned how to send messages to a specific client in the SignalR: Send a Message to a Specific Client in ASP.NET Core article. However, while implementing a SignalR solution, we often come across an issue of connection getting lost intermittently. Once the SignalR disconnects, we lose the real-time behavior of our application. Then, we need to establish the connection again for the application to function properly. SignalR does not reconnect automatically after a disconnection.

Previously, we had to implement custom mechanisms for the automatic reconnection of SignalR. But that is not a straightforward task. We need to take many things into account while implementing such a solution. The good news is that, in ASP.NET Core 3.0, SignalR introduced the automatic reconnect feature which will help us to solve this problem.

To download the source code for this article, you can visit our Real-Time Charts With SignalR And Angular repository.

Let’s move on.

Why Does a SignalR Connection Drop?

SignalR prefers WebSockets, which enable two-way communication between the browser and the server. That means it will use WebSockets whenever available, and gracefully fall back to Server-Sent Events or long polling when a WebSocket connection cannot be established. If the difference between the two models is new, our how WebSockets compare with REST article covers it.

A WebSocket stays open until something closes it: a server restart or deployment, an idle timeout on a proxy or load balancer in front of the app, a network change on the client, or the browser suspending an inactive tab.

Let’s take a look at the SignalR app that we created in the previous article:

signalr disconnect after some time

We can see that once we run the app and keep it idle, the SignalR disconnects after some time. Once it disconnects, we can see an error message in the browser console.

On losing the connection, SignalR does not automatically attempt to reconnect. So we’ll lose the real-time behavior of our app. Then, the only way to re-establish the connection is by refreshing the page. This presents an unpleasant experience for the users.

What Does withAutomaticReconnect() Do?

SignalR does not reconnect on its own. withAutomaticReconnect() on HubConnectionBuilder opts a connection in, and it has to be called explicitly because opting in is not the default.

With no argument, the client waits 0, 2, 10 and 30 seconds before each of four reconnect attempts, then stops. Passing an array of millisecond delays replaces both the timing and the count: the length of the array is how many attempts the client makes.

The connection does not go straight to closed while this happens. It moves to the Reconnecting state and fires onreconnecting, and it reaches onclose only once every attempt has failed.

One case is not covered, and it is the one people assume is. withAutomaticReconnect() does nothing about a failed initial start(). If the first connection attempt fails, the promise rejects, the connection is Disconnected, and no retry is scheduled.

Handling that first failure is our own code, usually a catch on start() that tries again on a timer.

The feature arrived in ASP.NET Core 3.0, and the SignalR JavaScript client has to be version 3.0.0 or above for it to work. Writing the default schedule out by hand looks like withAutomaticReconnect([0, 2000, 10000, 30000]) — four delays, so four attempts.

Is this material useful to you? Consider subscribing and get ASP.NET Core Web API Best Practices eBook for FREE!

How Do We Set Custom SignalR Retry Delays?

Two arguments customise the retry schedule, and they answer different questions.

An array of millisecond delays is the simple form. withAutomaticReconnect([0, 0, 10000]) retries immediately, retries immediately again, waits ten seconds for a third attempt, and then stops, because three delays mean three attempts.

An object implementing IRetryPolicy is the general form. Its single method, nextRetryDelayInMilliseconds, receives a RetryContext and returns the milliseconds to wait, or null to stop retrying altogether.

The context is what makes the policy worth writing. It carries previousRetryCount, elapsedMilliseconds and retryReason, so the delay can grow with the attempt number, jitter can be added to stop every client reconnecting in the same instant, and retrying can stop after a wall-clock budget rather than an attempt count.

Backing off matters more than it looks. Every client that dropped is retrying against a server that has just come back, and a fixed short delay turns a recovery into a second outage.

The general form of that reasoning — backoff, jitter and giving up — is the subject of our retry logic in C# article.

Adding Automatic Reconnect to the Hub Connection

We have looked at the automatic reconnect option available in SignalR. Now, without any further ado, let’s implement the same in our project. We are going to modify the project that we created in the linked article.

So, we are going to modify the startConnection() method of the SignalRService class to enable automatic reconnect:

this.hubConnection = new signalR.HubConnectionBuilder()
                        .withUrl('https://localhost:5001/chart')
                        .withAutomaticReconnect()
                        .configureLogging(signalR.LogLevel.Information)
                        .build();

Here, we call the withAutomaticReconnect() method without any parameters. So this will wait for 0, 2, 10, and 30 seconds respectively before attempting each reconnection. Finally, it will stop after four failed attempts. Of course, we can customize this by passing a set of custom values as the argument.

Furthermore, we have made a call to the configureLogging() method by passing the LogLevel argument as LogLevel.Information. This will configure console logging for the HubConnection instance. The LogLevel.Information argument will enable logging of all events of severity Information or more into the console. We’ve done this so that we can get a log of all events happening in the background.

That’s it. We have configured our SignalR app for automatic reconnection.

Testing The SignalR Automatic Reconnect Feature

Now, let’s test the automatic reconnection behavior of our SignalR app.

For that, we have to run both our server and client app. We can see that the Angular app displays the chart. The server-side application keeps on updating the chart values at regular intervals:

signalr app running

Now, we are going to simulate a WebSocket disconnection scenario. For that, we just need to stop our server application for a few seconds and start it again. We can see that the real-time updates on our Angular app stop for some time when the server application is stopped. But, once our server application is back online, the WebSocket connection will be automatically established again. Cool!

Let’s observe the console to understand the events happening in the background: 

signalr automatic reconnect

Here, we can see that initially the WebSocket connection is established and our client app gets real-time data from the server application.

Then, once we stop the server application, the WebSocket gets disconnected. It immediately makes the first attempt to reconnect, but fails.

Is this material useful to you? Consider subscribing and get ASP.NET Core Web API Best Practices eBook for FREE!

After that, it attempts another reconnect in 2 seconds, which fails as well. We can see that the next attempt is made after 10 seconds and by that time, our server application is back online and then the connection becomes successful.

That is the best case, and it is worth knowing what the other one looks like. The gaps grow, so the fourth attempt is scheduled a full thirty seconds after the third one fails, and the client sleeps that whole delay rather than polling. A server that comes back during the last gap therefore leaves the app dead for up to thirty seconds longer than it needs to be.

There is also no fifth attempt. After the fourth one fails the connection closes for good, and only a page refresh or our own code brings it back.

Requesting Real Time Data After Reconnection

At this point, we have enabled our client app to automatically reconnect to our server app using SignalR, but we don’t get real-time data now. The connection is back again, as we can see in our presentation, but the chart is frozen since we don’t get any new data from the server.

To enable the data flow once the connection is alive again, we have to use the onreconnected function that we can call with our hubConnection property inside the Signalr service:

import { HttpClient } from '@angular/common/http';

@Injectable({
  providedIn: 'root'
})
export class SignalrService {
  constructor(private http: HttpClient) {}

  ...

  this.hubConnection.onreconnected(() => {
    this.http.get('https://localhost:5001/api/chart')
    .subscribe(res => {
      console.log(res);
    })
  })
}

The HttpClient import and the constructor injection are what make this.http available inside the service; without them the handler does not compile.

This time, as soon as our app is reconnected, we will receive the data from the server.

Which SignalR Reconnect Events Should We Handle?

Three callbacks cover the whole lifecycle, and the article’s example uses one of them.

onreconnecting runs the moment the connection is lost, before the first retry, and receives the error that closed it. It is where a send button gets disabled and the user gets told the app is offline.

onreconnected runs when a retry succeeds, and it receives a connection id. That id is a new one: the server treats a reconnect as an entirely new connection, so anything stored against the old id is now pointing at nothing.

onclose runs when the retries are exhausted, and at that point the connection is finished. Nothing further is scheduled, so this is where the user is told a refresh is needed.

Refetching state belongs in onreconnected rather than in the handler for incoming data. Whatever the server pushed while the client was retrying is gone, and only an explicit request gets it back.

APIWhen it runsWhat it hands usWhat belongs there
onreconnecting(cb)The connection is lost and retrying is about to startThe Error that closed the connectionDisable anything that sends, and tell the user the app is offline
onreconnected(cb)A retry succeedsA new connectionId — never the old oneRe-enable the UI, replace anything stored against the old id, and re-request state the client missed
onclose(cb)The retries are exhausted, or stop() was calledundefined after exhausted retries; the Error only when the connection closed without a retry policyTell the user the connection is gone and a refresh is needed
connection.stateAlways readableConnected, Reconnecting, Disconnected, Connecting or DisconnectingGuard sends — invoking while Reconnecting rejects
connection.connectionIdAlways readableThe live id, and null for the whole time the client is ReconnectingRead it, never cache it

ASP.NET Core 8.0 added a related feature this article predates: stateful reconnect. The client opts in with .withStatefulReconnect() and the server opts in per hub:

app.MapHub<ChartHub>("/chart", options => { options.AllowStatefulReconnects = true; });

With it enabled, both sides buffer and acknowledge messages and replay whatever was in flight, so a blip costs no data, and StatefulReconnectBufferSize caps that buffer (100,000 bytes by default). The boundary matters more than the feature. The buffer lives in the server process, so stateful reconnect survives a transport blip and not a server restart — and a server restart is exactly the scenario we tested above. Measured on .NET 10 with the buffer enabled on both sides, restarting the server still produced a brand-new connection id.

The endpoint our onreconnected handler calls is a controller holding an IHubContext<ChartHub>, and our calling a SignalR hub from a controller article explains what is on the other end of that request. For the same chart against a different front end, see the same real-time chart in Blazor WebAssembly.

Conclusion

In this article we looked at the following topics:  

  • The disconnection problem of SignalR
  • Automatic Reconnect feature of SignalR
  • How to implement the automatic reconnection in our SignalR app

Tested with .NET 10.0.10, Angular 13.2 and @microsoft/signalr 6.0.4.