Real-Time Notifications with SignalR in Enterprise Mobile Apps
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 DigiPort, our React Native app for 2,000+ employees, 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:
- 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.
- No third-party dependency for real-time. Government clients are wary of routing sensitive data through Google's servers. SignalR runs on our infrastructure.
- Shared hub with the web app. DigiFlow (our React web app) already uses SignalR. The same hub serves both web and mobile clients.
- 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:
csharppublic 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.
typescriptimport { 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:
typescriptuseEffect(() => {
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:
csharppublic 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:
typescriptconnection.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 after 60 seconds are re-sent. After 3 failed attempts, they fall back to FCM push notifications. This three-tier delivery system — SignalR, retry, FCM fallback — gives us a 99.8% delivery rate.
Production Metrics
After running this system for 6 months with 500+ daily active users:
- Average notification delivery time: 180ms (SignalR) vs 2-4 seconds (FCM)
- SignalR connection uptime: 98.5% during business hours
- Notification delivery rate: 99.8%
- Battery impact: negligible — SignalR uses WebSocket which the OS manages efficiently
Key Takeaways
- Use SignalR for in-app real-time, FCM for background push. They complement each other.
- Stop reconnecting in the background. Mobile batteries matter.
- Track connections in Redis when users have multiple devices.
- Acknowledge notifications to guarantee delivery.
- Fall back gracefully. SignalR -> retry -> FCM is a robust delivery chain.