Index · How it works

1 min read
Mid-level5 min read
Rapid overview

How it works

SignalR is a library for ASP.NET that simplifies adding real-time web functionality. It provides an abstraction over WebSockets with automatic fallbacks and built-in features for connection management, broadcasting, and scaling.


Core SignalR Concepts

Hub - The Communication Endpoint

A Hub is a high-level pipeline that allows clients and servers to call methods on each other:

public class NotificationHub : Hub
{
    public override async Task OnConnectedAsync()
    {
        var userId = Context.User?.FindFirst(ClaimTypes.NameIdentifier)?.Value;
        var tenantId = Context.User?.FindFirst("tenant_id")?.Value;

        if (!string.IsNullOrEmpty(userId))
        {
            // Add user to their personal group
            await Groups.AddToGroupAsync(Context.ConnectionId, $"user:{userId}");
        }

        if (!string.IsNullOrEmpty(tenantId))
        {
            // Add user to their tenant group for multi-tenant scenarios
            await Groups.AddToGroupAsync(Context.ConnectionId, $"tenant:{tenantId}");
        }

        await base.OnConnectedAsync();
    }

    public override async Task OnDisconnectedAsync(Exception? exception)
    {
        // Groups are automatically cleaned up on disconnect
        await base.OnDisconnectedAsync(exception);
    }

    // Client can call this method
    public async Task SendMessage(string message)
    {
        var userId = Context.User?.FindFirst(ClaimTypes.NameIdentifier)?.Value;
        await Clients.Group($"user:{userId}").SendAsync("ReceiveMessage", message);
    }
}

Groups - Targeted Broadcasting

Groups allow you to organize connections and send messages to specific subsets:

// Add connection to a group
await Groups.AddToGroupAsync(connectionId, "tenant:acme-corp");

// Send to all connections in a group
await Clients.Group("tenant:acme-corp").SendAsync("Notification", payload);

// Send to multiple groups
await Clients.Groups(new[] { "group1", "group2" }).SendAsync("Broadcast", data);

// Exclude specific connections
await Clients.GroupExcept("tenant:acme-corp", excludedConnectionIds)
    .SendAsync("Notification", payload);

Connection Lifecycle

public class NotificationHub : Hub
{
    private readonly IConnectionTracker _tracker;

    public NotificationHub(IConnectionTracker tracker)
    {
        _tracker = tracker;
    }

    public override async Task OnConnectedAsync()
    {
        await _tracker.AddConnection(Context.ConnectionId, Context.User);
        await base.OnConnectedAsync();
    }

    public override async Task OnDisconnectedAsync(Exception? exception)
    {
        await _tracker.RemoveConnection(Context.ConnectionId);

        if (exception != null)
        {
            // Log unexpected disconnection
            // Consider sending notification about connection loss
        }

        await base.OnDisconnectedAsync(exception);
    }
}

Client Implementation

JavaScript/TypeScript Client

import * as signalR from "@microsoft/signalr";

class NotificationService {
  private connection: signalR.HubConnection;
  private reconnectAttempts = 0;
  private maxReconnectAttempts = 10;

  constructor(private getAccessToken: () => Promise<string>) {
    this.connection = new signalR.HubConnectionBuilder()
      .withUrl("/hubs/notifications", {
        accessTokenFactory: this.getAccessToken,
        // Skip negotiation for WebSocket-only (faster connection)
        skipNegotiation: true,
        transport: signalR.HttpTransportType.WebSockets
      })
      .withAutomaticReconnect({
        nextRetryDelayInMilliseconds: (retryContext) => {
          // Exponential backoff with jitter
          if (retryContext.previousRetryCount >= this.maxReconnectAttempts) {
            return null; // Stop reconnecting
          }
          const baseDelay = Math.min(1000 * Math.pow(2, retryContext.previousRetryCount), 30000);
          const jitter = Math.random() * 1000;
          return baseDelay + jitter;
        }
      })
      .configureLogging(signalR.LogLevel.Information)
      .build();

    this.setupEventHandlers();
  }

  private setupEventHandlers(): void {
    this.connection.on("ReceiveNotification", (notification: Notification) => {
      this.handleNotification(notification);
    });

    this.connection.onreconnecting((error) => {
      console.log("Reconnecting...", error);
      this.updateConnectionStatus("reconnecting");
    });

    this.connection.onreconnected((connectionId) => {
      console.log("Reconnected with ID:", connectionId);
      this.updateConnectionStatus("connected");
      this.reconnectAttempts = 0;
    });

    this.connection.onclose((error) => {
      console.log("Connection closed", error);
      this.updateConnectionStatus("disconnected");
    });
  }

  async start(): Promise<void> {
    try {
      await this.connection.start();
      console.log("SignalR Connected");
      this.updateConnectionStatus("connected");
    } catch (err) {
      console.error("SignalR Connection Error:", err);
      // Retry after delay
      setTimeout(() => this.start(), 5000);
    }
  }

  async stop(): Promise<void> {
    await this.connection.stop();
  }

  private handleNotification(notification: Notification): void {
    // Dispatch to notification system
    // Show toast, update UI, etc.
  }

  private updateConnectionStatus(status: string): void {
    // Update UI to show connection status
  }
}

.NET Client

public class SignalRNotificationClient : IAsyncDisposable
{
    private readonly HubConnection _connection;
    private readonly ILogger<SignalRNotificationClient> _logger;

    public event Action<Notification>? OnNotificationReceived;

    public SignalRNotificationClient(
        string hubUrl,
        Func<Task<string>> accessTokenProvider,
        ILogger<SignalRNotificationClient> logger)
    {
        _logger = logger;

        _connection = new HubConnectionBuilder()
            .WithUrl(hubUrl, options =>
            {
                options.AccessTokenProvider = accessTokenProvider;
                options.Transports = HttpTransportType.WebSockets;
                options.SkipNegotiation = true;
            })
            .WithAutomaticReconnect(new ExponentialBackoffRetryPolicy())
            .Build();

        _connection.On<Notification>("ReceiveNotification", notification =>
        {
            OnNotificationReceived?.Invoke(notification);
        });

        _connection.Reconnecting += error =>
        {
            _logger.LogWarning(error, "Connection lost, reconnecting...");
            return Task.CompletedTask;
        };

        _connection.Reconnected += connectionId =>
        {
            _logger.LogInformation("Reconnected with ID: {ConnectionId}", connectionId);
            return Task.CompletedTask;
        };
    }

    public async Task StartAsync(CancellationToken cancellationToken = default)
    {
        await _connection.StartAsync(cancellationToken);
    }

    public async ValueTask DisposeAsync()
    {
        await _connection.DisposeAsync();
    }
}

public class ExponentialBackoffRetryPolicy : IRetryPolicy
{
    private readonly int _maxRetries = 10;

    public TimeSpan? NextRetryDelay(RetryContext retryContext)
    {
        if (retryContext.PreviousRetryCount >= _maxRetries)
            return null;

        var delay = TimeSpan.FromSeconds(Math.Pow(2, retryContext.PreviousRetryCount));
        var jitter = TimeSpan.FromMilliseconds(Random.Shared.Next(0, 1000));

        return delay + jitter;
    }
}