A scalable, stateless, asynchronous user notification system built on SignalR, RabbitMQ, and Redis Pub/Sub. The goal: target a specific user and deliver the message to all devices they are connected on, regardless of which server instance handles the connection.
Requirements
- Scalable — multiple instances can be spawned without coupling
- Asynchronous — service bus (RabbitMQ) as the notification medium
- Stateless — SignalR clients on different servers, publishers on others; no direct coupling
- User-targeted — notification reaches all connected devices of a specific user
- Correlation ID tracked from publish through to delivery
Design

How It Works
Subscription side:
- User logs in and connects to a SignalR hub on any instance
- The hub maps the connection ID to the user ID
- The hub simultaneously subscribes to a Redis Pub/Sub channel keyed to that user
Publisher side:
- A service publishes an event to RabbitMQ containing the target user ID
- A worker consumes the message and pushes it to the user's Redis channel
- Any SignalR instance subscribed to that Redis channel forwards it to the connected client
Redis Pub/Sub is used for its simplicity and fire-and-forget semantics — no persistence needed, and it instantly delivers to all active subscribers at minimal cost. RabbitMQ provides buffering and time-to-live control so the system is never overwhelmed.
Library
The library is available on GitHub: ramihamati/hubnotificationsystem
Example — Subscriber

Pass the bearer token through the SignalR query string and configure JWT events:
services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer("Bearer", options =>
{
options.BackchannelHttpHandler
= HttpConfiguredClientFactory.CreateMessageHandler(
SettingsConfiguredHttpClients.HTTPS, Configuration);
options.Authority = Configuration["EndPoints:IdentityAuthority"];
options.RequireHttpsMetadata = false;
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateAudience = false,
ValidAudience = "comx.notifications.hubapi"
};
// SignalR: pass access_token from query string to hub
options.Events = new JwtBearerEvents
{
OnMessageReceived = context =>
{
var accessToken = context.Request.Query["access_token"];
var path = context.HttpContext.Request.Path;
if (!string.IsNullOrEmpty(accessToken) &&
path.StartsWithSegments(SYSTEM_UPDATE_HUB))
{
context.Token = accessToken;
}
return Task.CompletedTask;
}
};
});Register the Redis notification manager and map the SignalR hub endpoint:
// Register the Redis notification manager
services.AddRedisManager<SettingsRedisConnection>(builder =>
{
builder.AddManager<HubSystemUpdate, IHubSystemUpdate>(options =>
{
options.RegisterController<RedisControllerSystemUpdate, HubSystemUpdate, IHubSystemUpdate>();
options.RegisterRoute<PubSubHubAsActive>(Routes.PubSubHubAsActive);
options.RegisterRoute<PubSubHubDeleted>(Routes.PubSubHubDeleted);
options.RegisterRoute<PubSubHubEdited>(Routes.PubSubHubEdited);
options.RegisterRoute<PubSubHubCreated>(Routes.PubSubHubCreated);
options.UseKeyBuilder<KeyBuilderSystemUpdate>();
options.UseNotificationEvents<NotificationEventsLogger>();
});
});
services.AddSignalR();
// In Configure:
app.UseEndpoints(endpoints =>
{
endpoints.MapHub<HubSystemUpdate>(SYSTEM_UPDATE_HUB);
endpoints.MapHealthChecks("/health");
});Each Redis message is dispatched to a controller method registered by route:
public class RedisControllerSystemUpdate
: NotificationControllerBase<HubSystemUpdate, IHubSystemUpdate>
{
public IHubContext<HubSystemUpdate, IHubSystemUpdate> HubContext { get; }
public ILogger<RedisControllerSystemUpdate> Logger { get; }
public RedisControllerSystemUpdate(
IHubContext<HubSystemUpdate, IHubSystemUpdate> hubContext,
ILogger<RedisControllerSystemUpdate> logger)
{
HubContext = hubContext;
Logger = logger;
}
[NotificationRoute(Routes.PubSubHubAsActive)]
public async Task HandleHubAsActive(PubSubHubAsActive notification)
{
try
{
IHubSystemUpdate clientHub = GetSubsriberHub(HubContext);
await clientHub.HubSetAsActive();
}
catch (Exception ex)
{
Logger.LogError(ex, "Error processing redis message PubSubHubAsActive");
}
}
}The hub declares only the client callback interface — subscribe/unsubscribe is handled by the base class:
public interface IHubSystemUpdate
{
Task HubSetAsActive();
Task HubDeleted(Guid hubId);
Task HubEdited(Guid hubId);
Task HubCreated(Guid hubId);
}
public class HubSystemUpdate : NotificationHub<HubSystemUpdate, IHubSystemUpdate>
{
public HubSystemUpdate(INotificationPubSubProvider provider) : base(provider)
{
}
}
// Base class — subscribe/unsubscribe handled automatically
[Authorize]
public class NotificationHub<THub, THubActions> : Hub
where THub : Hub
where THubActions : class
{
public NotificationHub(INotificationPubSubProvider provider);
public override Task OnDisconnectedAsync(Exception exception);
public void Subscribe();
public void Unubscribe();
}Example — Publisher
In the worker project, register the publisher provider with matching routes:
services.AddNotificationPublisherProvider<SettingsRedisConnection>(builder =>
{
builder.AddManager("SYSTEMUPDATE", options =>
{
options.RegisterRoute<PubSubHubAsActive>(Routes.PubSubHubAsActive);
options.RegisterRoute<PubSubHubDeleted>(Routes.PubSubHubDeleted);
options.RegisterRoute<PubSubHubEdited>(Routes.PubSubHubEdited);
options.RegisterRoute<PubSubHubCreated>(Routes.PubSubHubCreated);
options.UseKeyBuilder<KeyBuilderSystemUpdate>();
options.UseNotificationEvents<NotificationEventsLogger>();
});
});The RabbitMQ message handler resolves the publisher client and pushes to the target user's Redis channel:
public class EventSystemUpdateHandler : IntegrationEventHandler<EventSystemUpdate>
{
private readonly INotificationPublisherFactory _publisherManager;
private readonly ILogger<EventSystemUpdateHandler> _logger;
public EventSystemUpdateHandler(
INotificationPublisherFactory publisherProvider,
ILogger<EventSystemUpdateHandler> logger)
{
_publisherManager = publisherProvider;
_logger = logger;
}
public override Task<HandlerResult> HandleAsync(EventSystemUpdate @event)
{
NotificationPublisherClient publisherManager =
_publisherManager.GetPublisherClient("SYSTEMUPDATE");
switch (@event.Type)
{
case EventSystemUpdateType.SetHubAsActive:
SuSetHubAsActive activeModel = (SuSetHubAsActive)EventSystemUpdate.To(@event);
NotificationPublisher publisher =
publisherManager.GetPublisherFor(activeModel.UserId.ToString());
publisher.Publish(new PubSubHubAsActive());
return Task.FromResult(HandlerResult.SetAcknowledged());
// ... other event types
}
}
}