Client Reconnection Strategies for SignalR

Real-time connections drop. Networks switch, laptops sleep, servers restart. The difference between a good SignalR application and a frustrating one is how it handles these interruptions. Let's look at the strategies that keep your clients connected.

Built-In Automatic Reconnection

The SignalR JavaScript client has a withAutomaticReconnect() option. By default, it attempts to reconnect at 0, 2, 10, and 30 seconds. After the fourth failure, it gives up:

script.js
const connection = new signalR.HubConnectionBuilder()
    .withUrl("/hubs/chat")
    .withAutomaticReconnect()
    .build();

You can customise the retry intervals:

script.js
const connection = new signalR.HubConnectionBuilder()
    .withUrl("/hubs/chat")
    .withAutomaticReconnect([0, 1000, 5000, 10000, 30000, 60000])
    .build();

This tries immediately, then at 1, 5, 10, 30, and 60 seconds.

Custom Retry Policies

For more sophisticated backoff, implement IRetryPolicy:

script.js
class ExponentialRetryPolicy {
    nextRetryDelayInMilliseconds(retryContext) {
        // Give up after 5 minutes
        if (retryContext.elapsedMilliseconds > 300000) {
            return null;
        }

        // Exponential backoff: 1s, 2s, 4s, 8s, 16s... capped at 30s
        const delay = Math.min(
            1000 * Math.pow(2, retryContext.previousRetryCount),
            30000
        );

        // Add jitter to prevent thundering herd
        return delay + Math.random() * 1000;
    }
}

const connection = new signalR.HubConnectionBuilder()
    .withUrl("/hubs/chat")
    .withAutomaticReconnect(new ExponentialRetryPolicy())
    .build();

Jitter is important — without it, all disconnected clients retry at exactly the same intervals, potentially overwhelming your server.

Handling Reconnection Events

SignalR exposes three events during the reconnection lifecycle:

script.js
connection.onreconnecting((error) => {
    console.log("Connection lost. Attempting to reconnect...", error);
    showBanner("Reconnecting...", "warning");
    disableInput();
});

connection.onreconnected((connectionId) => {
    console.log("Reconnected with ID:", connectionId);
    hideBanner();
    enableInput();
    rejoinGroups();
});

connection.onclose((error) => {
    console.log("Connection closed permanently.", error);
    showBanner("Disconnected. Please refresh.", "error");
});

The onclose event fires when automatic reconnection has been exhausted. At this point, you need to either prompt the user or implement manual reconnection.

Manual Reconnection After Close

When onclose fires, automatic reconnection has given up. You can restart it manually:

script.js
connection.onclose(async (error) => {
    showBanner("Disconnected. Trying to reconnect...", "error");
    await reconnectWithBackoff();
});

async function reconnectWithBackoff() {
    let delay = 1000;
    const maxDelay = 60000;

    while (true) {
        try {
            await connection.start();
            hideBanner();
            await rejoinGroups();
            return;
        } catch (err) {
            console.log(`Reconnect failed, retrying in ${delay}ms`);
            await new Promise(resolve => setTimeout(resolve, delay));
            delay = Math.min(delay * 2, maxDelay);
        }
    }
}

State Recovery After Reconnection

When a client reconnects, it gets a new connection ID. Any group memberships, subscriptions, or server-side state are lost. You need to re-establish them:

script.js
async function rejoinGroups() {
    // Re-subscribe to any channels the user was watching
    for (const roomId of activeRooms) {
        await connection.invoke("JoinRoom", roomId);
    }

    // Request any messages missed during disconnection
    if (lastMessageTimestamp) {
        await connection.invoke("GetMessagesSince", lastMessageTimestamp);
    }
}

On the server side, support this with a "catch-up" mechanism:

Example.cs
public class ChatHub : Hub<IChatClient>
{
    private readonly IMessageStore _messageStore;

    public ChatHub(IMessageStore messageStore)
    {
        _messageStore = messageStore;
    }

    public async Task GetMessagesSince(DateTime since)
    {
        var missedMessages = await _messageStore
            .GetMessagesAfterAsync(since);

        foreach (var message in missedMessages)
        {
            await Clients.Caller.ReceiveMessage(
                message.User, message.Text);
        }
    }
}

.NET Client Reconnection

The .NET client follows similar patterns:

Example.cs
var connection = new HubConnectionBuilder()
    .WithUrl("https://example.com/hubs/chat")
    .WithAutomaticReconnect(new ExponentialBackoffPolicy())
    .Build();

connection.Reconnecting += error =>
{
    Console.WriteLine($"Reconnecting: {error?.Message}");
    return Task.CompletedTask;
};

connection.Reconnected += async connectionId =>
{
    Console.WriteLine($"Reconnected: {connectionId}");
    await RejoinGroupsAsync();
};

connection.Closed += async error =>
{
    Console.WriteLine($"Closed: {error?.Message}");
    await Task.Delay(5000);
    await connection.StartAsync();
};

Custom retry policy in .NET:

Example.cs
public class ExponentialBackoffPolicy : IRetryPolicy
{
    public TimeSpan? NextRetryDelay(RetryContext retryContext)
    {
        if (retryContext.ElapsedTime > TimeSpan.FromMinutes(5))
            return null;

        var delay = TimeSpan.FromSeconds(
            Math.Pow(2, retryContext.PreviousRetryCount));

        if (delay > TimeSpan.FromSeconds(30))
            delay = TimeSpan.FromSeconds(30);

        // Add jitter
        var jitter = TimeSpan.FromMilliseconds(
            Random.Shared.Next(0, 1000));

        return delay + jitter;
    }
}

User Experience Patterns

Good reconnection handling is as much about UX as it is about code:

Key Takeaways