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:
const connection = new signalR.HubConnectionBuilder()
.withUrl("/hubs/chat")
.withAutomaticReconnect()
.build();
You can customise the retry intervals:
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:
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:
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:
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:
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:
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:
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:
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:
- Show a non-intrusive banner during reconnection attempts.
- Disable input fields to prevent sending messages into the void.
- Queue outgoing messages during disconnection and send them after reconnection.
- Show a timestamp for the last received message so users know what they might have missed.
- Provide a manual "Reconnect" button after automatic reconnection fails.
Key Takeaways
- Always use
withAutomaticReconnect()— the defaults are sensible for most cases. - Implement exponential backoff with jitter for custom retry policies.
- Re-establish group memberships and state after every reconnection.
- Build a server-side catch-up mechanism for missed messages.
- Provide clear visual feedback during disconnection and reconnection.