.NET
06 / 08

SignalR & Background Services

.NET: SignalR & Background Services

SignalR — Real-Time Communication

SignalR abstracts WebSockets, Server-Sent Events, and Long Polling behind a single Hub API. Clients can receive server pushes without polling.

// Program.cs
builder.Services.AddSignalR();
app.MapHub<ChatHub>("/hubs/chat");

// Hubs/ChatHub.cs
public class ChatHub : Hub
{
    // Called by client
    public async Task SendMessage(string room, string message)
    {
        await Clients.Group(room).SendAsync("ReceiveMessage", new {
            User = Context.User?.Identity?.Name,
            Message = message,
            At = DateTime.UtcNow,
        });
    }

    public async Task JoinRoom(string room)
    {
        await Groups.AddToGroupAsync(Context.ConnectionId, room);
        await Clients.Group(room).SendAsync("UserJoined", Context.ConnectionId);
    }

    public override async Task OnDisconnectedAsync(Exception? exception)
    {
        // cleanup
        await base.OnDisconnectedAsync(exception);
    }
}

// Push from outside a Hub (e.g., from a service)
public class NotificationService(IHubContext<ChatHub> hub)
{
    public async Task NotifyAsync(string userId, string message) =>
        await hub.Clients.User(userId).SendAsync("Notification", message);
}
// TypeScript client
import { HubConnectionBuilder, LogLevel } from '@microsoft/signalr'

const connection = new HubConnectionBuilder()
  .withUrl('/hubs/chat', { accessTokenFactory: () => getToken() })
  .withAutomaticReconnect()
  .configureLogging(LogLevel.Information)
  .build()

connection.on('ReceiveMessage', (data) => {
  console.log(`${data.user}: ${data.message}`)
})

await connection.start()
await connection.invoke('JoinRoom', 'general')
await connection.invoke('SendMessage', 'general', 'Hello!')

Background Services

// IHostedService — runs alongside the web server
// BackgroundService — abstract base class (preferred)

public class EmailQueueProcessor : BackgroundService
{
    private readonly IServiceScopeFactory _scopeFactory;
    private readonly ILogger<EmailQueueProcessor> _logger;

    public EmailQueueProcessor(IServiceScopeFactory scopeFactory, ILogger<EmailQueueProcessor> logger)
    {
        _scopeFactory = scopeFactory;
        _logger = logger;
    }

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        _logger.LogInformation("Email processor started");

        while (!stoppingToken.IsCancellationRequested)
        {
            try
            {
                using var scope = _scopeFactory.CreateScope();  // Scoped services need a scope
                var emailService = scope.ServiceProvider.GetRequiredService<IEmailService>();
                await emailService.ProcessQueueAsync(stoppingToken);
            }
            catch (Exception ex) when (!stoppingToken.IsCancellationRequested)
            {
                _logger.LogError(ex, "Error processing email queue");
            }

            await Task.Delay(TimeSpan.FromSeconds(30), stoppingToken);
        }
    }
}

// Register
builder.Services.AddHostedService<EmailQueueProcessor>();

// Worker Service project (console app, no HTTP)
// dotnet new worker -n MyWorker

Channels — Producer/Consumer

// System.Threading.Channels — async queue built into .NET
// Use for producer/consumer pipelines within a process

public class WorkQueue
{
    private readonly Channel<WorkItem> _channel =
        Channel.CreateBounded<WorkItem>(new BoundedChannelOptions(500) {
            FullMode = BoundedChannelFullMode.Wait,
        });

    public async ValueTask EnqueueAsync(WorkItem item, CancellationToken ct = default) =>
        await _channel.Writer.WriteAsync(item, ct);

    public IAsyncEnumerable<WorkItem> ReadAllAsync(CancellationToken ct = default) =>
        _channel.Reader.ReadAllAsync(ct);
}

// Producer (e.g., API endpoint)
await _queue.EnqueueAsync(new WorkItem(payload));

// Consumer (background service)
await foreach (var item in _queue.ReadAllAsync(stoppingToken))
{
    await ProcessAsync(item);
}

// Hangfire — persistent background jobs (survives restarts)
// dotnet add package Hangfire.AspNetCore Hangfire.SqlServer
builder.Services.AddHangfire(cfg => cfg.UseSqlServerStorage(connStr));
builder.Services.AddHangfireServer();
app.MapHangfireDashboard("/hangfire");

BackgroundJob.Enqueue(() => Console.WriteLine("Fire and forget"));
BackgroundJob.Schedule(() => SendReminder(userId), TimeSpan.FromDays(1));
RecurringJob.AddOrUpdate("cleanup", () => Cleanup(), Cron.Daily);

Keep your own version of these notes — editable, searchable, and organised by your stack.

Start free