Angular SignalR Service — HubConnection, Reconnection and RxJS Integration

📋 Table of Contents
  1. Angular SignalR Service
  2. Common Mistakes

The Angular SignalR service wraps the @microsoft/signalr client library in a testable, reactive service. It manages the connection lifecycle (start, stop, reconnect), exposes hub events as RxJS Subjects that Angular components can subscribe to, and handles authentication by providing the current JWT to the connection factory. The key pattern: SignalR events become Observables that integrate naturally with Angular’s reactive data flow.

Angular SignalR Service

// npm install @microsoft/signalr
import { HubConnection, HubConnectionBuilder, HubConnectionState,
         LogLevel } from '@microsoft/signalr';

@Injectable({ providedIn: 'root' })
export class SignalRService implements OnDestroy {
  private auth   = inject(AuthService);
  private config = inject(APP_CONFIG);

  private _connection: HubConnection | null = null;

  // ── Connection state ────────────────────────────────────────────────────
  connectionState = signal<HubConnectionState>(HubConnectionState.Disconnected);

  // ── Hub event streams — components subscribe to these ───────────────────
  readonly comments$        = new Subject<CommentDto>();
  readonly viewerCount$     = new Subject<{ postId: number; count: number }>();
  readonly notifications$   = new Subject<NotificationDto>();

  // ── Build and start the connection ──────────────────────────────────────
  async connect(): Promise<void> {
    if (this._connection?.state === HubConnectionState.Connected) return;

    this._connection = new HubConnectionBuilder()
      .withUrl(`${this.config.apiUrl}/hubs/blog`, {
        // Provide JWT for WebSocket connections (can't use headers)
        accessTokenFactory: () => this.auth.accessToken() ?? '',
        withCredentials:    true,
      })
      .withAutomaticReconnect({
        nextRetryDelayInMilliseconds: retryContext => {
          // Exponential backoff: 0, 2, 10, 30 seconds
          const delays = [0, 2000, 10000, 30000];
          return delays[retryContext.previousRetryCount] ?? 30000;
        }
      })
      .configureLogging(
        this.config.production ? LogLevel.Warning : LogLevel.Information
      )
      .build();

    // ── Wire up server-to-client events ──────────────────────────────────
    this._connection.on('ReceiveComment',
      (comment: CommentDto) => this.comments$.next(comment));

    this._connection.on('UpdateViewerCount',
      (postId: number, count: number) => this.viewerCount$.next({ postId, count }));

    this._connection.on('ReceiveNotification',
      (notification: NotificationDto) => this.notifications$.next(notification));

    // ── Track connection state ────────────────────────────────────────────
    this._connection.onreconnecting(() =>
      this.connectionState.set(HubConnectionState.Reconnecting));
    this._connection.onreconnected(() =>
      this.connectionState.set(HubConnectionState.Connected));
    this._connection.onclose(() =>
      this.connectionState.set(HubConnectionState.Disconnected));

    try {
      await this._connection.start();
      this.connectionState.set(HubConnectionState.Connected);
    } catch (err) {
      console.error('SignalR connection failed:', err);
      this.connectionState.set(HubConnectionState.Disconnected);
    }
  }

  // ── Disconnect ────────────────────────────────────────────────────────
  async disconnect(): Promise<void> {
    await this._connection?.stop();
    this._connection = null;
  }

  // ── Client-to-server method invocations ────────────────────────────────
  async joinPostRoom(postId: number): Promise<void> {
    await this._connection?.invoke('JoinPostRoom', postId);
  }

  async leavePostRoom(postId: number): Promise<void> {
    await this._connection?.invoke('LeavePostRoom', postId);
  }

  async sendComment(postId: number, body: string): Promise<void> {
    await this._connection?.invoke('SendComment', postId, body);
  }

  ngOnDestroy(): void { this.disconnect(); }
}

// ── Integrate with auth lifecycle ──────────────────────────────────────────
// In AuthService.setSession():
// this.signalR.connect();   // connect when logged in
// In AuthService.clearSession():
// this.signalR.disconnect(); // disconnect when logged out
Note: The accessTokenFactory function is called by the SignalR client on every connection attempt (including reconnections) — not just the initial connection. This means when the JWT access token is refreshed (by the Angular auth interceptor), the next reconnection automatically uses the new token. The factory reads the current token from auth.accessToken() (a Signal) — always up to date. Without this, a reconnection after token refresh would use the expired token and fail auth.
Tip: Use RxJS Subjects for hub events rather than Angular EventEmitters. Subjects integrate with the full RxJS operator pipeline — components can pipe(takeUntilDestroyed(destroyRef)), apply filter(), debounceTime(), or share() on the event stream. EventEmitters are a subset of Subjects but without the full Observable API. Since SignalR events are global (the service is a singleton), any component that subscribes must unsubscribe when destroyed — takeUntilDestroyed handles this cleanly.
Warning: The withAutomaticReconnect() configuration handles temporary disconnections (network hiccup, server restart) but does not handle the case where the JWT token expires during a reconnection attempt. If the token expires and the reconnection uses the expired token, auth fails and the connection stays disconnected. The accessTokenFactory reads the current token at reconnection time — if the token refresh has already run (via the auth service’s scheduled timer), the factory returns the new token. If not, the reconnection will fail and onclose fires — the component should prompt the user to reload.

Common Mistakes

Mistake 1 — Not unsubscribing from hub Subjects in components (memory leak)

❌ Wrong — signalR.comments$.subscribe(c => ...) without takeUntilDestroyed; subscription lives after component destruction.

✅ Correct — signalR.comments$.pipe(takeUntilDestroyed(destroyRef)).subscribe(...).

Mistake 2 — Connecting SignalR before authentication (fails with 401)

❌ Wrong — SignalRService.connect() called in APP_INITIALIZER before auth session restored; connection fails.

✅ Correct — connect() called inside AuthService.setSession() (after successful login or session restore).

🧠 Test Yourself

The SignalR connection drops (network issue). withAutomaticReconnect fires with delays [0, 2000, 10000, 30000]. How many retry attempts are made before the client gives up?