Frontend Advanced 18 min

Real-Time Updates in Blazor Server with NATS

Implement real-time UI updates in Blazor Server using NATS JetStream instead of SignalR, with progress indicators and state management.

By Victor Robin Updated:

When I first configured NATS as the real-time transport for our Blazor Server UI, I naively subscribed to NATS directly from within Razor components. The result was a tangle of connection management issues: components would lose their subscription when the circuit reconnected, duplicate messages appeared after hot reloads, and worst of all, disposed components would still receive messages and crash when calling StateHasChanged. It took a complete rethink, introducing an in-process notification service as an intermediary, before the system became stable. That architectural pivot is the foundation of everything described in this article.

Introduction

When a user uploads a document, they shouldn’t stare at a spinner wondering if anything is happening. They should see real-time progress: “OCR processing… 45%… Entity extraction… Complete!”

The typical solution is SignalR, but Blazor Server already uses SignalR internally for its circuit connection. Adding another SignalR layer for real-time events creates redundancy. Instead, we’ll leverage our existing NATS messaging infrastructure to push updates directly to Blazor components.

[NATS.io Documentation] — Synadia Communications , 2024-09-01

What We’ll Build

In this guide, we’ll implement:

  1. In-process notification service — Decouples NATS from Blazor components
  2. NATS event listener — Bridges external events to the notification service
  3. Base component class — Handles subscriptions, filtering, and cleanup
  4. Document uploader — Shows real-time upload and processing progress
  5. Live document list — Updates automatically when documents change

Why NATS Instead of SignalR?

  • Consistency: Workers already publish to NATS; no need for a separate notification channel
  • Simplicity: One messaging system instead of two
  • Scalability: NATS handles cross-pod communication naturally; SignalR needs Redis backplane
  • Already there: No additional infrastructure to manage
[NATS JetStream] — Synadia Communications , 2024-09-01

Architecture Overview

The data flow from worker to UI:

flowchart LR
    Worker["⚙️ Worker\n(Publishes)"] --> NATS["⚡ NATS\n(JetStream)"]
    NATS --> Listener["👂 Listener\n(Hosted)"]
    Listener --> Notifier["📢 Notifier\n(Service)"]
    Notifier --> CompA["🟣 Component A"]
    Notifier --> CompB["🟣 Component B"]
    Notifier --> CompC["🟣 Component C"]

    classDef primary fill:#7c3aed,color:#fff
    classDef secondary fill:#06b6d4,color:#fff
    classDef db fill:#f43f5e,color:#fff
    classDef warning fill:#fbbf24,color:#000

    class Worker,Listener,Notifier,CompA,CompB,CompC primary
    class NATS secondary

Key insight: The DocumentProcessingNotifier acts as an in-process event bus. The NATS listener translates external messages to C# events; components subscribe to only the documents they care about.

The Notification Service

The notification service provides an in-process event aggregation layer. Components subscribe to it; they never know about NATS directly.

Why this indirection?

  • Testability: Unit tests can call NotifyDocumentUpdatedAsync without NATS
  • Flexibility: Could swap NATS for another source without changing components
  • Filtering: Components subscribe to specific document IDs; the service broadcasts to all
// Application/Services/IDocumentProcessingNotifier.cs
namespace MyApp.Application.Services;

public interface IDocumentProcessingNotifier
{
    /// <summary>
    /// Event raised when a document's processing status changes.
    /// </summary>
    event Func<DocumentStatusUpdate, Task>? OnDocumentUpdated;

    /// <summary>
    /// Event raised for processing progress updates.
    /// </summary>
    event Func<DocumentProgressUpdate, Task>? OnProgressUpdated;

    /// <summary>
    /// Notifies subscribers of a document status change.
    /// </summary>
    Task NotifyDocumentUpdatedAsync(
        string documentId,
        DocumentStatus status,
        string? message = null);

    /// <summary>
    /// Notifies subscribers of processing progress.
    /// </summary>
    Task NotifyProgressUpdatedAsync(
        string documentId,
        string stage,
        int percentage,
        string? detail = null);
}

public record DocumentStatusUpdate(
    string DocumentId,
    DocumentStatus Status,
    string? Message,
    DateTimeOffset Timestamp);

public record DocumentProgressUpdate(
    string DocumentId,
    string Stage,
    int Percentage,
    string? Detail,
    DateTimeOffset Timestamp);

Implementation

The implementation iterates through handlers, catching exceptions to prevent one bad handler from breaking the notification chain:

[Background tasks with hosted services in ASP.NET Core] — Microsoft , 2024-11-12
// Web/Services/DocumentProcessingNotifier.cs
namespace MyApp.Web.Services;

public sealed class DocumentProcessingNotifier : IDocumentProcessingNotifier
{
    private readonly ILogger<DocumentProcessingNotifier> _logger;

    public event Func<DocumentStatusUpdate, Task>? OnDocumentUpdated;
    public event Func<DocumentProgressUpdate, Task>? OnProgressUpdated;

    public DocumentProcessingNotifier(ILogger<DocumentProcessingNotifier> logger)
    {
        _logger = logger;
    }

    public async Task NotifyDocumentUpdatedAsync(
        string documentId,
        DocumentStatus status,
        string? message = null)
    {
        var update = new DocumentStatusUpdate(
            documentId,
            status,
            message,
            DateTimeOffset.UtcNow);

        _logger.LogDebug(
            "Notifying status update: {DocumentId} -> {Status}",
            documentId,
            status);

        var handlers = OnDocumentUpdated;
        if (handlers is not null)
        {
            // Invoke all handlers, catching exceptions to avoid breaking the chain
            foreach (var handler in handlers.GetInvocationList()
                .Cast<Func<DocumentStatusUpdate, Task>>())
            {
                try
                {
                    await handler(update);
                }
                catch (Exception ex)
                {
                    _logger.LogError(ex,
                        "Error in document update handler for {DocumentId}",
                        documentId);
                }
            }
        }
    }

    public async Task NotifyProgressUpdatedAsync(
        string documentId,
        string stage,
        int percentage,
        string? detail = null)
    {
        var update = new DocumentProgressUpdate(
            documentId,
            stage,
            Math.Clamp(percentage, 0, 100),  // Ensure valid range
            detail,
            DateTimeOffset.UtcNow);

        var handlers = OnProgressUpdated;
        if (handlers is not null)
        {
            foreach (var handler in handlers.GetInvocationList()
                .Cast<Func<DocumentProgressUpdate, Task>>())
            {
                try
                {
                    await handler(update);
                }
                catch (Exception ex)
                {
                    _logger.LogError(ex,
                        "Error in progress handler for {DocumentId}",
                        documentId);
                }
            }
        }
    }
}

NATS Event Listener

This hosted service bridges NATS events to the notification service. It uses an ephemeral consumer since we only care about real-time updates—if the web server restarts, we don’t need to replay missed events.

[NATS Consumers] — Synadia Communications , 2024-09-01
// Web/Services/NatsDocumentEventListener.cs
namespace MyApp.Web.Services;

public sealed class NatsDocumentEventListener : BackgroundService
{
    private readonly INatsConnection _nats;
    private readonly IDocumentProcessingNotifier _notifier;
    private readonly ILogger<NatsDocumentEventListener> _logger;
    private readonly string _environment;

    public NatsDocumentEventListener(
        INatsConnection nats,
        IDocumentProcessingNotifier notifier,
        IConfiguration configuration,
        ILogger<NatsDocumentEventListener> logger)
    {
        _nats = nats;
        _notifier = notifier;
        _logger = logger;
        _environment = configuration["Environment"] ?? "dev";
    }

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        var js = _nats.CreateJetStreamContext();

        // Ephemeral consumer: unique name, short inactivity timeout, no ack required
        var consumer = await js.CreateOrUpdateConsumerAsync(
            stream: "DOCUMENTS",
            config: new ConsumerConfig
            {
                // Unique name per instance for horizontal scaling
                Name = $"web-{Environment.MachineName}-{Guid.NewGuid():N}",
                // Subscribe to all document events for this environment
                FilterSubject = $"{_environment}.archives.documents.>",
                // Only receive new messages (not replays)
                DeliverPolicy = ConsumerConfigDeliverPolicy.New,
                // Fire-and-forget: missing a notification isn't critical
                AckPolicy = ConsumerConfigAckPolicy.None,
                // Auto-cleanup when this pod dies
                InactiveThreshold = TimeSpan.FromMinutes(5)
            },
            stoppingToken);

        _logger.LogInformation(
            "NATS document event listener started for {Environment}",
            _environment);

        try
        {
            await foreach (var msg in consumer.ConsumeAsync<DocumentEventEnvelope>(
                cancellationToken: stoppingToken))
            {
                await HandleEventAsync(msg.Subject, msg.Data!, stoppingToken);
            }
        }
        catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
        {
            _logger.LogInformation("NATS document event listener stopping");
        }
    }

Event mapping: The listener translates NATS subjects to notification calls. Subject structure is {env}.archives.documents.{event-type}:

    private async Task HandleEventAsync(
        string subject,
        DocumentEventEnvelope envelope,
        CancellationToken ct)
    {
        try
        {
            // Extract event type from subject: "dev.archives.documents.ocr-completed" -> "ocr-completed"
            var eventType = subject.Split('.').Last();

            switch (eventType)
            {
                case "uploaded":
                    await _notifier.NotifyDocumentUpdatedAsync(
                        envelope.DocumentId,
                        DocumentStatus.Uploaded,
                        "Document queued for processing");
                    break;

                case "ocr-started":
                    await _notifier.NotifyDocumentUpdatedAsync(
                        envelope.DocumentId,
                        DocumentStatus.Processing,
                        "Starting OCR...");
                    await _notifier.NotifyProgressUpdatedAsync(
                        envelope.DocumentId,
                        "OCR",
                        0,
                        "Initializing document processor");
                    break;

                case "ocr-progress":
                    if (envelope.Progress is not null)
                    {
                        await _notifier.NotifyProgressUpdatedAsync(
                            envelope.DocumentId,
                            "OCR",
                            envelope.Progress.Percentage,
                            envelope.Progress.Detail);
                    }
                    break;

                case "ocr-completed":
                    await _notifier.NotifyProgressUpdatedAsync(
                        envelope.DocumentId,
                        "OCR",
                        100,
                        "Text extraction complete");
                    break;

                case "analysis-completed":
                    await _notifier.NotifyProgressUpdatedAsync(
                        envelope.DocumentId,
                        "Analysis",
                        100,
                        "AI analysis complete");
                    break;

                case "embedding-completed":
                    await _notifier.NotifyProgressUpdatedAsync(
                        envelope.DocumentId,
                        "Indexing",
                        100,
                        "Search index updated");
                    break;

                case "indexed":
                    await _notifier.NotifyDocumentUpdatedAsync(
                        envelope.DocumentId,
                        DocumentStatus.Ready,
                        "Document ready for search");
                    break;

                case "error":
                    await _notifier.NotifyDocumentUpdatedAsync(
                        envelope.DocumentId,
                        DocumentStatus.Error,
                        envelope.Error ?? "Processing failed");
                    break;
            }
        }
        catch (Exception ex)
        {
            _logger.LogError(ex,
                "Error handling event {Subject} for {DocumentId}",
                subject,
                envelope.DocumentId);
        }
    }
}

public record DocumentEventEnvelope
{
    public required string DocumentId { get; init; }
    public ProgressInfo? Progress { get; init; }
    public string? Error { get; init; }
}

public record ProgressInfo(int Percentage, string? Detail);

Service Registration

// Program.cs
builder.Services.AddSingleton<IDocumentProcessingNotifier, DocumentProcessingNotifier>();
builder.Services.AddHostedService<NatsDocumentEventListener>();

Blazor Component Base

Now the Blazor side. We create a base component that handles the subscription boilerplate and—critically—cleanup when components dispose.

Why cleanup matters: Blazor Server components can be disposed at any time (navigation, circuit disconnect). Without unsubscribing, you get memory leaks and exceptions when handlers try to invoke StateHasChanged on disposed components.

[ASP.NET Core Blazor component lifecycle] — Microsoft , 2024-11-12
// Components/AppComponentBase.cs
namespace MyApp.Web.Components;

public abstract class AppComponentBase : ComponentBase, IDisposable
{
    [Inject]
    protected IDocumentProcessingNotifier Notifier { get; set; } = default!;

    [Inject]
    protected ILogger<AppComponentBase> Logger { get; set; } = default!;

    private readonly List<IDisposable> _subscriptions = new();
    private bool _disposed;

    /// <summary>
    /// Subscribe to updates for a specific document.
    /// Automatically filters events and triggers StateHasChanged.
    /// </summary>
    protected void SubscribeToDocumentUpdates(
        string documentId,
        Func<DocumentStatusUpdate, Task> handler)
    {
        // Wrap handler to filter by document ID and trigger re-render
        async Task FilteredHandler(DocumentStatusUpdate update)
        {
            if (update.DocumentId == documentId)
            {
                await handler(update);
                await InvokeAsync(StateHasChanged);
            }
        }

        Notifier.OnDocumentUpdated += FilteredHandler;
        _subscriptions.Add(new EventSubscription(() =>
            Notifier.OnDocumentUpdated -= FilteredHandler));
    }

    /// <summary>
    /// Subscribe to progress updates for a specific document.
    /// </summary>
    protected void SubscribeToProgressUpdates(
        string documentId,
        Func<DocumentProgressUpdate, Task> handler)
    {
        async Task FilteredHandler(DocumentProgressUpdate update)
        {
            if (update.DocumentId == documentId)
            {
                await handler(update);
                await InvokeAsync(StateHasChanged);
            }
        }

        Notifier.OnProgressUpdated += FilteredHandler;
        _subscriptions.Add(new EventSubscription(() =>
            Notifier.OnProgressUpdated -= FilteredHandler));
    }

    /// <summary>
    /// Subscribe to all document updates (useful for lists).
    /// </summary>
    protected void SubscribeToAllDocumentUpdates(
        Func<DocumentStatusUpdate, Task> handler)
    {
        async Task WrappedHandler(DocumentStatusUpdate update)
        {
            await handler(update);
            await InvokeAsync(StateHasChanged);
        }

        Notifier.OnDocumentUpdated += WrappedHandler;
        _subscriptions.Add(new EventSubscription(() =>
            Notifier.OnDocumentUpdated -= WrappedHandler));
    }

    public void Dispose()
    {
        Dispose(true);
        GC.SuppressFinalize(this);
    }

    protected virtual void Dispose(bool disposing)
    {
        if (_disposed) return;

        if (disposing)
        {
            // Unsubscribe from all events
            foreach (var subscription in _subscriptions)
            {
                subscription.Dispose();
            }
            _subscriptions.Clear();
        }

        _disposed = true;
    }

    /// <summary>
    /// Helper class to track subscriptions for cleanup.
    /// </summary>
    private sealed class EventSubscription : IDisposable
    {
        private Action? _unsubscribe;

        public EventSubscription(Action unsubscribe) => _unsubscribe = unsubscribe;

        public void Dispose()
        {
            _unsubscribe?.Invoke();
            _unsubscribe = null;
        }
    }
}

Document Upload Component

Now let’s build a complete upload component that shows real-time progress. This component:

  1. Handles file selection via drag-and-drop or file picker
  2. Uploads to the API
  3. Subscribes to processing events for real-time progress
  4. Updates the UI as events arrive
[NATS .NET Client] — NATS Authors , 2024-07-15
@* Components/Documents/DocumentUploader.razor *@
@inherits AppComponentBase

<div class="space-y-4">
    @* Upload Area *@
    <div class="@DropZoneClass"
         @ondragenter="HandleDragEnter"
         @ondragleave="HandleDragLeave"
         @ondragover:preventDefault
         @ondrop="HandleDrop"
         @ondrop:preventDefault>

        <InputFile OnChange="HandleFileSelected"
                   accept=".pdf,.docx,.pptx,.png,.jpg,.jpeg"
                   class="hidden"
                   id="file-input" />

        <label for="file-input" class="cursor-pointer flex flex-col items-center">
            <svg class="w-12 h-12 text-gray-400 mb-3" fill="none"
                 stroke="currentColor" viewBox="0 0 24 24">
                <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
                      d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"/>
            </svg>
            <span class="text-gray-600 dark:text-gray-300">
                Drop files here or click to browse
            </span>
            <span class="text-sm text-gray-400 mt-1">
                PDF, Word, PowerPoint, or images up to 50MB
            </span>
        </label>
    </div>

    @* Upload Queue *@
    @if (uploads.Count > 0)
    {
        <div class="space-y-2">
            @foreach (var upload in uploads)
            {
                <DocumentUploadCard Upload="upload" OnRemove="RemoveUpload" />
            }
        </div>
    }
</div>

@code {
    [Parameter] public string ArchiveId { get; set; } = default!;
    [Parameter] public EventCallback<string> OnUploadComplete { get; set; }

    private List<UploadState> uploads = new();
    private bool isDragOver;

    private string DropZoneClass => new CssBuilder()
        .AddClass("border-2 border-dashed rounded-xl p-8 text-center transition-colors")
        .AddClass("border-gray-300 dark:border-gray-600", !isDragOver)
        .AddClass("border-primary-blue bg-primary-blue/5", isDragOver)
        .Build();

    private void HandleDragEnter() => isDragOver = true;
    private void HandleDragLeave() => isDragOver = false;

    private async Task HandleDrop(DragEventArgs e)
    {
        isDragOver = false;
        // File handling via InputFile is more reliable
    }

    private async Task HandleFileSelected(InputFileChangeEventArgs e)
    {
        foreach (var file in e.GetMultipleFiles(10))
        {
            var upload = new UploadState(file.Name, file.Size);
            uploads.Add(upload);

            // Start upload asynchronously
            _ = UploadFileAsync(upload, file);
        }
    }

    private async Task UploadFileAsync(UploadState upload, IBrowserFile file)
    {
        try
        {
            upload.Status = UploadStatus.Uploading;
            StateHasChanged();

            // Subscribe to updates for this document
            // (DocumentId will be set after API response)

            using var content = new MultipartFormDataContent();
            using var stream = file.OpenReadStream(maxAllowedSize: 50 * 1024 * 1024);
            using var streamContent = new StreamContent(stream);

            content.Add(streamContent, "file", file.Name);
            content.Add(new StringContent(ArchiveId), "archiveId");

            var response = await Http.PostAsync("/api/documents/upload", content);
            response.EnsureSuccessStatusCode();

            var result = await response.Content.ReadFromJsonAsync<UploadResponse>();
            upload.DocumentId = result!.DocumentId;
            upload.Status = UploadStatus.Processing;

            // Now subscribe to NATS updates for this specific document
            SubscribeToDocumentUpdates(upload.DocumentId, update =>
            {
                upload.StatusMessage = update.Message;

                if (update.Status == DocumentStatus.Ready)
                {
                    upload.Status = UploadStatus.Complete;
                    OnUploadComplete.InvokeAsync(upload.DocumentId);
                }
                else if (update.Status == DocumentStatus.Error)
                {
                    upload.Status = UploadStatus.Error;
                    upload.Error = update.Message;
                }

                return Task.CompletedTask;
            });

            SubscribeToProgressUpdates(upload.DocumentId, progress =>
            {
                upload.Stage = progress.Stage;
                upload.Progress = progress.Percentage;
                upload.ProgressDetail = progress.Detail;
                return Task.CompletedTask;
            });
        }
        catch (Exception ex)
        {
            upload.Status = UploadStatus.Error;
            upload.Error = ex.Message;
            Logger.LogError(ex, "Upload failed for {FileName}", file.Name);
        }

        StateHasChanged();
    }

    private void RemoveUpload(UploadState upload)
    {
        uploads.Remove(upload);
    }

    [Inject] private HttpClient Http { get; set; } = default!;
}

public class UploadState
{
    public string FileName { get; }
    public long FileSize { get; }
    public string? DocumentId { get; set; }
    public UploadStatus Status { get; set; } = UploadStatus.Pending;
    public string? Stage { get; set; }
    public int Progress { get; set; }
    public string? ProgressDetail { get; set; }
    public string? StatusMessage { get; set; }
    public string? Error { get; set; }

    public UploadState(string fileName, long fileSize)
    {
        FileName = fileName;
        FileSize = fileSize;
    }
}

public enum UploadStatus { Pending, Uploading, Processing, Complete, Error }

public record UploadResponse(string DocumentId);

Upload Card with Progress

The upload card displays file info, status, and a progress bar. The key is the progress bar that updates in real-time as events arrive:

@* Components/Documents/DocumentUploadCard.razor *@

<div class="glass-card p-4">
    <div class="flex items-center gap-4">
        @* File Icon *@
        <div class="w-10 h-10 rounded-lg bg-primary-blue/10 flex items-center justify-center">
            <FileIcon FileName="@Upload.FileName" />
        </div>

        @* File Info *@
        <div class="flex-1 min-w-0">
            <p class="font-medium text-gray-900 dark:text-white truncate">
                @Upload.FileName
            </p>
            <p class="text-sm text-gray-500">
                @FormatBytes(Upload.FileSize)
                @if (!string.IsNullOrEmpty(Upload.StatusMessage))
                {
                    <span class="ml-2">• @Upload.StatusMessage</span>
                }
            </p>
        </div>

        @* Status Indicator *@
        <div class="flex items-center gap-2">
            @switch (Upload.Status)
            {
                case UploadStatus.Pending:
                    <span class="text-gray-400">Waiting...</span>
                    break;

                case UploadStatus.Uploading:
                    <div class="animate-spin w-5 h-5 border-2 border-primary-blue
                                border-t-transparent rounded-full" />
                    <span class="text-primary-blue">Uploading</span>
                    break;

                case UploadStatus.Processing:
                    <div class="animate-pulse w-5 h-5 bg-amber-400 rounded-full" />
                    <span class="text-amber-600 dark:text-amber-400">Processing</span>
                    break;

                case UploadStatus.Complete:
                    <svg class="w-5 h-5 text-green-500" fill="currentColor" viewBox="0 0 20 20">
                        <path fill-rule="evenodd"
                              d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
                              clip-rule="evenodd" />
                    </svg>
                    <span class="text-green-600 dark:text-green-400">Complete</span>
                    break;

                case UploadStatus.Error:
                    <svg class="w-5 h-5 text-red-500" fill="currentColor" viewBox="0 0 20 20">
                        <path fill-rule="evenodd"
                              d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
                              clip-rule="evenodd" />
                    </svg>
                    <span class="text-red-600 dark:text-red-400">Failed</span>
                    break;
            }

            @* Remove Button *@
            @if (Upload.Status is UploadStatus.Complete or UploadStatus.Error)
            {
                <button @onclick="() => OnRemove.InvokeAsync(Upload)"
                        class="p-1 text-gray-400 hover:text-gray-600 rounded">
                    <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
                        <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
                              d="M6 18L18 6M6 6l12 12" />
                    </svg>
                </button>
            }
        </div>
    </div>

    @* Progress Bar *@
    @if (Upload.Status == UploadStatus.Processing && Upload.Progress > 0)
    {
        <div class="mt-3">
            <div class="flex justify-between text-xs text-gray-500 mb-1">
                <span>@Upload.Stage</span>
                <span>@Upload.Progress%</span>
            </div>
            <div class="h-1.5 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
                <div class="h-full bg-primary-blue transition-all duration-300 rounded-full"
                     style="width: @(Upload.Progress)%" />
            </div>
            @if (!string.IsNullOrEmpty(Upload.ProgressDetail))
            {
                <p class="text-xs text-gray-400 mt-1">@Upload.ProgressDetail</p>
            }
        </div>
    }

    @* Error Message *@
    @if (Upload.Status == UploadStatus.Error && !string.IsNullOrEmpty(Upload.Error))
    {
        <p class="mt-2 text-sm text-red-500">@Upload.Error</p>
    }
</div>

@code {
    [Parameter, EditorRequired] public UploadState Upload { get; set; } = default!;
    [Parameter] public EventCallback<UploadState> OnRemove { get; set; }

    private string FormatBytes(long bytes)
    {
        string[] sizes = ["B", "KB", "MB", "GB"];
        double len = bytes;
        int order = 0;
        while (len >= 1024 && order < sizes.Length - 1)
        {
            order++;
            len /= 1024;
        }
        return $"{len:0.##} {sizes[order]}";
    }
}

Document List with Live Updates

Finally, a document list that updates in real-time when new documents are added or statuses change. This uses SubscribeToAllDocumentUpdates since it needs to react to any document, not just a specific one:

@* Components/Documents/DocumentList.razor *@
@inherits AppComponentBase
@implements IAsyncDisposable

<div class="space-y-4">
    @foreach (var doc in documents)
    {
        <DocumentCard Document="doc" />
    }

    @if (documents.Count == 0)
    {
        <div class="text-center py-12 text-gray-500">
            <p>No documents yet</p>
        </div>
    }
</div>

@code {
    [Parameter] public string ArchiveId { get; set; } = default!;

    private List<DocumentViewModel> documents = new();

    protected override async Task OnInitializedAsync()
    {
        // Load initial documents
        documents = await LoadDocumentsAsync();

        // Subscribe to all document updates for this archive
        SubscribeToAllDocumentUpdates(async update =>
        {
            var doc = documents.FirstOrDefault(d => d.Id == update.DocumentId);

            if (doc is not null)
            {
                // Update existing document
                doc.Status = update.Status;
                doc.UpdatedAt = update.Timestamp;
            }
            else if (update.Status == DocumentStatus.Uploaded)
            {
                // New document - refresh list
                documents = await LoadDocumentsAsync();
            }
        });
    }

    private async Task<List<DocumentViewModel>> LoadDocumentsAsync()
    {
        var response = await Http.GetFromJsonAsync<DocumentListResponse>(
            $"/api/archives/{ArchiveId}/documents");
        return response?.Documents ?? new();
    }

    [Inject] private HttpClient Http { get; set; } = default!;

    public async ValueTask DisposeAsync()
    {
        // AppComponentBase.Dispose handles unsubscription
        Dispose();
        await ValueTask.CompletedTask;
    }
}

Conclusion

We’ve built a complete real-time update system for Blazor Server that leverages existing NATS infrastructure:

ComponentPurpose
IDocumentProcessingNotifierIn-process event aggregation
NatsDocumentEventListenerBridges NATS → notification service
AppComponentBaseSubscription management + cleanup
DocumentUploaderReal-time upload + processing progress
DocumentListLive list updates

Key patterns:

  • Event filtering: Components subscribe to specific document IDs; filtering happens locally
  • Automatic cleanup: Base component disposes subscriptions to prevent memory leaks
  • Fire-and-forget NATS: No acks needed for UI notifications; missed events aren’t critical
  • InvokeAsync(StateHasChanged): Always marshal UI updates to the Blazor synchronization context

This approach keeps the architecture simple: one messaging system (NATS) for both backend workers and frontend notifications, no SignalR hubs to configure, no Redis backplane for scaling.

Reflecting on this implementation, the decision to use NATS instead of adding a second SignalR layer was one of the best architectural choices we made. It reduced our infrastructure complexity and gave us a consistent event model from backend workers all the way to the UI. The in-process notification service pattern has proven remarkably robust in production; the only issue we ever encountered was the ephemeral consumer lesson described above, and that was resolved within hours of the first deployment.

Next Steps

  • Add heartbeat monitoring to detect stale NATS connections and trigger automatic reconnection.
  • Implement batch notification debouncing to prevent UI thrashing when many documents update simultaneously.
  • Build a notification history panel so users can review past processing events for audit purposes.

Further Reading

[NATS JetStream Documentation] — NATS Authors , 2024 [ASP.NET Core Blazor component lifecycle] — Microsoft , 2024 [Background tasks with hosted services] — Microsoft , 2024