User Notification System — Digitteck
User Notification System
system-engineering·1 December 2020·5 min read

User Notification System

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

User notification system architecture diagram

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

Subscriber project structure

Pass the bearer token through the SignalR query string and configure JWT events:

csharp
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:

csharp
// 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:

csharp
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:

csharp
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:

csharp
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:

csharp
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
        }
    }
}

Tags

ASP.NETSignalRRedisRabbitMQDistributed
digitteck

© 2026 Digitteck