Back to Blog
SignalRReact NativeReal-TimeWebSocketPush Notifications

Real-Time Notifications with SignalR in Enterprise Mobile Apps

Umut Korkmaz2025-09-159 min

Push notifications are expected in any mobile app, but in an enterprise workflow application, real-time updates are not just nice-to-have — they are critical. When a document needs approval, the approver should know within seconds, not minutes. When I built the mobile app, a React Native app for internal teams, I chose SignalR over Firebase Cloud Messaging for the real-time layer, and the decision paid off in ways I did not expect.

Why SignalR Over FCM

Firebase Cloud Messaging is the standard answer for mobile push notifications, and we do use it for background notifications when the app is not running. But for in-app real-time updates, SignalR has significant advantages in an enterprise context:

  1. Bidirectional communication. FCM is one-way. SignalR lets the client send messages back to the server, which is useful for read receipts and typing indicators.
  2. No third-party dependency for real-time. Security-conscious organizations are wary of routing sensitive data through Google's servers. SignalR runs on our infrastructure.
  3. Shared hub with the web app. the internal platform (our React web app) already uses SignalR. The same hub serves both web and mobile clients.
  4. Structured messages. SignalR sends typed objects, not string payloads that need parsing.

Hub Design

The SignalR hub is intentionally simple. It handles connection management and message routing, not business logic:

csharp
[Authorize]
public class WorkflowHub : Hub
{
    private readonly IConnectionManager _connectionManager;
    private readonly ILogger<WorkflowHub> _logger;

    public override async Task OnConnectedAsync()
    {
        var userId = Context.User!.GetUserId();
        var departments = Context.User.GetDepartmentIds();

        await _connectionManager.AddConnectionAsync(
            userId, Context.ConnectionId);

        // Join department-specific groups
        foreach (var deptId in departments)
        {
            await Groups.AddToGroupAsync(
                Context.ConnectionId, $"dept:{deptId}");
        }

        _logger.LogInformation(
            "User {UserId} connected with {ConnectionId}",
            userId, Context.ConnectionId);

        await base.OnConnectedAsync();
    }

    public override async Task OnDisconnectedAsync(Exception? exception)
    {
        var userId = Context.User!.GetUserId();
        await _connectionManager.RemoveConnectionAsync(
            userId, Context.ConnectionId);

        await base.OnDisconnectedAsync(exception);
    }

    // Client can acknowledge receipt of a notification
    public async Task AcknowledgeNotification(string notificationId)
    {
        var userId = Context.User!.GetUserId();
        await _connectionManager.MarkDeliveredAsync(
            notificationId, userId);
    }
}

Business events are published to the hub from MediatR event handlers, keeping the hub clean:

csharp
public class DocumentApprovedEventHandler
    : INotificationHandler<DocumentApprovedEvent>
{
    private readonly IHubContext<WorkflowHub> _hubContext;
    private readonly IConnectionManager _connectionManager;

    public async Task Handle(
        DocumentApprovedEvent notification, CancellationToken ct)
    {
        var recipientId = notification.RequesterId;
        var connections = await _connectionManager
            .GetConnectionsAsync(recipientId);

        if (connections.Any())
        {
            await _hubContext.Clients.Clients(connections)
                .SendAsync("DocumentApproved", new
                {
                    documentId = notification.DocumentId,
                    approvedBy = notification.ApproverName,
                    step = notification.CurrentStep,
                    timestamp = DateTimeOffset.UtcNow,
                }, ct);
        }
        else
        {
            // User is offline - queue for push notification via FCM
            await _connectionManager.QueueOfflineNotification(
                recipientId, notification);
        }
    }
}

Notice the fallback: if the user has no active SignalR connections, we queue the notification for FCM delivery. This hybrid approach ensures no notification is lost.

React Native Client

On the mobile side, the SignalR connection needs to handle the realities of mobile networking: intermittent connectivity, app backgrounding, and token expiration.

typescript
import { HubConnectionBuilder, LogLevel } from '@microsoft/signalr';

class SignalRService {
  private connection: HubConnection | null = null;
  private isAppActive = true;

  async connect() {
    this.connection = new HubConnectionBuilder()
      .withUrl(`${Config.API_URL}/hubs/workflow`, {
        accessTokenFactory: async () => {
          const token = await tokenManager.getAccessToken();
          return token;
        },
      })
      .withAutomaticReconnect({
        nextRetryDelayInMilliseconds: (ctx) => {
          if (!this.isAppActive) return null; // Don't retry in background
          return Math.min(Math.pow(2, ctx.previousRetryCount) * 1000, 30_000);
        },
      })
      .configureLogging(LogLevel.Warning)
      .build();

    this.registerHandlers();
    await this.connection.start();
  }

  private registerHandlers() {
    if (!this.connection) return;

    this.connection.on('DocumentApproved', (data) => {
      useNotificationStore.getState().addNotification({
        id: crypto.randomUUID(),
        type: 'approval',
        title: 'Document Approved',
        body: `${data.approvedBy} approved your document`,
        documentId: data.documentId,
        timestamp: data.timestamp,
        read: false,
      });
    });

    this.connection.on('NewTask', (data) => {
      useNotificationStore.getState().addNotification({
        id: crypto.randomUUID(),
        type: 'task',
        title: 'New Task Assigned',
        body: data.description,
        documentId: data.documentId,
        timestamp: data.timestamp,
        read: false,
      });
    });

    this.connection.onreconnecting(() => {
      useNotificationStore.getState().setConnectionStatus(false);
    });

    this.connection.onreconnected(() => {
      useNotificationStore.getState().setConnectionStatus(true);
    });
  }

  setAppState(active: boolean) {
    this.isAppActive = active;
    if (active && this.connection?.state === 'Disconnected') {
      this.connect(); // Reconnect when app comes to foreground
    }
  }
}

The key detail is setAppState. When the app goes to the background, we stop reconnection attempts to save battery. When it comes back to the foreground, we reconnect immediately. This ties into React Native's AppState API:

typescript
useEffect(() => {
  const subscription = AppState.addEventListener('change', (state) => {
    signalRService.setAppState(state === 'active');
  });
  return () => subscription.remove();
}, []);

Connection Manager with Redis

Supporting multiple devices per user requires a connection manager backed by Redis:

csharp
public class RedisConnectionManager : IConnectionManager
{
    private readonly IConnectionMultiplexer _redis;
    private const string PREFIX = "signalr:connections:";

    public async Task AddConnectionAsync(string userId, string connectionId)
    {
        var db = _redis.GetDatabase();
        await db.SetAddAsync($"{PREFIX}{userId}", connectionId);
        await db.KeyExpireAsync($"{PREFIX}{userId}", TimeSpan.FromHours(12));
    }

    public async Task<IReadOnlyList<string>> GetConnectionsAsync(string userId)
    {
        var db = _redis.GetDatabase();
        var connections = await db.SetMembersAsync($"{PREFIX}{userId}");
        return connections.Select(c => c.ToString()).ToList();
    }
}

A user might have the web app open on their desktop and the mobile app on their phone. Both receive the notification simultaneously because both connection IDs are stored in the Redis set.

Delivery Guarantees

The acknowledgment pattern ensures we know a notification was actually received:

typescript
connection.on('DocumentApproved', async (data) => {
  // Add to local state
  useNotificationStore.getState().addNotification(/* ... */);

  // Acknowledge receipt to server
  await connection.invoke('AcknowledgeNotification', data.notificationId);
});

On the server side, unacknowledged notifications are retried before falling back to FCM push notifications. This layered delivery system — SignalR, retry, FCM fallback — helps keep delivery reliable.

Production Metrics

After running this system in production with steady day-to-day usage:

  • Notification delivery stays fast for in-app updates
  • SignalR connectivity remains stable during active usage
  • Fallback delivery keeps notifications reliable when the app is offline
  • Battery impact stays low because WebSocket usage is managed efficiently by the OS

Key Takeaways

  1. Use SignalR for in-app real-time, FCM for background push. They complement each other.
  2. Stop reconnecting in the background. Mobile batteries matter.
  3. Track connections in Redis when users have multiple devices.
  4. Acknowledge notifications to guarantee delivery.
  5. Fall back gracefully. SignalR -> retry -> FCM is a robust delivery chain.