Sarıkaya Dev Logo

Build a Real-Time Collaborative Whiteboard with .NET 8 Minimal APIs, SignalR, and Cosmos DB Change Feed

Mahmut Sarıkaya 5 min read 1 Views 0
Build a Real-Time Collaborative Whiteboard with .NET 8 Minimal APIs, SignalR, and Cosmos DB Change Feed

Why real‑time collaboration matters for modern apps

Imagine a remote design sprint where three engineers sketch architecture diagrams on a shared canvas, and each line appears instantly for every participant. According to a 2023 Stack Overflow survey, 68% of developers consider latency the biggest obstacle to effective collaboration tools. Reducing that latency to milliseconds can turn a frustrating experience into a seamless workflow.

Architecture overview

The solution combines three .NET‑centric components: a Minimal API that exposes HTTP endpoints, a SignalR hub that pushes live updates to connected browsers, and Azure Cosmos DB’s Change Feed that guarantees every stroke is persisted and replayed to late‑joining clients. The data flow is simple: a client draws a stroke → the browser sends a JSON payload to the SignalR hub → the hub writes the stroke to Cosmos DB → the Change Feed processor reads the new document and broadcasts it back to all clients. This decouples write latency from read latency and provides built‑in durability.

Setting up a .NET 8 Minimal API project

Start with .NET 8 SDK (release November 2023) and the dotnet new web template. The minimal program file replaces the old Startup.cs and keeps the code under 30 lines.

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSignalR();
builder.Services.AddSingleton<CosmosClient>(sp =>
    new CosmosClient(builder.Configuration["Cosmos:ConnectionString"]));
var app = builder.Build();
app.MapGet("/", () => "Whiteboard API is running");
app.MapHub<WhiteboardHub>("/whiteboardHub");
app.Run();

Notice the use of AddSignalR() and a singleton CosmosClient—both are thread‑safe and ideal for high‑throughput scenarios.

Creating the SignalR hub

The hub receives drawing commands and forwards them to every other participant. Keep the payload small; a typical stroke consists of an array of points, a color hex, and a thickness value, totaling about 150 bytes.

public class WhiteboardHub : Hub
{
    public async Task DrawStroke(StrokeDto stroke)
    {
        // Persist first, then broadcast
        await Context.GetHttpContext().RequestServices
            .GetRequiredService<CosmosClient>()
            .GetContainer("WhiteboardDB", "Strokes")
            .CreateItemAsync(stroke);
        await Clients.Others.SendAsync("ReceiveStroke", stroke);
    }
}

public record StrokeDto(Guid Id, string Color, int Thickness, PointDto[] Points);
public record PointDto(double X, double Y);

By persisting inside the hub method, you guarantee that every broadcasted stroke also lives in the database, which is essential for recovery after a crash.

Leveraging the Cosmos DB Change Feed

When a new client connects, it needs the full history of strokes. Rather than pulling the entire container, you can start a Change Feed processor that replays every document from the beginning. The processor runs as a background service inside the same Minimal API host.

builder.Services.AddHostedService<WhiteboardChangeFeedService>();

public class WhiteboardChangeFeedService : IHostedService
{
    private readonly CosmosClient _client;
    private readonly IHubContext<WhiteboardHub> _hubContext;
    private ChangeFeedProcessor _processor;
    public WhiteboardChangeFeedService(CosmosClient client, IHubContext<WhiteboardHub> hubContext)
    {
        _client = client;
        _hubContext = hubContext;
    }
    public async Task StartAsync(CancellationToken ct)
    {
        var container = _client.GetContainer("WhiteboardDB", "Strokes");
        var leaseContainer = _client.GetContainer("WhiteboardDB", "Leases");
        _processor = container.GetChangeFeedProcessorBuilder<StrokeDto>("whiteboardProcessor",
            async (IReadOnlyCollection<StrokeDto> changes, CancellationToken token) =>
            {
                foreach (var stroke in changes)
                {
                    await _hubContext.Clients.All.SendAsync("ReceiveStroke", stroke, token);
                }
            })
            .WithInstanceName("instance1")
            .WithLeaseContainer(leaseContainer)
            .Build();
        await _processor.StartAsync();
    }
    public async Task StopAsync(CancellationToken ct) => await _processor.StopAsync();
}

The lease container stores checkpoint information, so the processor can resume where it left off after a restart. This pattern scales automatically across multiple API instances.

Client‑side integration

On the browser, use the official @microsoft/signalr npm package. After establishing the connection, listen for ReceiveStroke and render the points on an HTML5 <canvas>. When the user draws, send the DrawStroke invocation.

const connection = new signalR.HubConnectionBuilder()
    .withUrl("/whiteboardHub")
    .withAutomaticReconnect()
    .build();
connection.on("ReceiveStroke", stroke => {
    drawOnCanvas(stroke);
});
await connection.start();
function sendStroke(stroke){
    connection.invoke("DrawStroke", stroke).catch(err => console.error(err));
}

Because the server already stores the stroke, the client does not need additional HTTP calls to fetch history; the Change Feed processor pushes the backlog automatically when the connection is established.

Performance tips and monitoring

1. **Batch writes** – Cosmos DB supports bulk mode; enable AllowBulkExecution = true on the client to reduce RU consumption when many strokes arrive simultaneously.
2. **Throttle handling** – SignalR automatically retries on transient failures, but you should also monitor the RateLimited event from the Cosmos SDK.
3. **Scaling** – Deploy the Minimal API to Azure App Service or Azure Container Apps with at least two instances. The Change Feed processor will balance the lease partitions across them, providing linear scaling.

Testing the end‑to‑end flow

Use dotnet test with a lightweight integration test that spins up an in‑memory SignalR client, sends a stroke, and asserts that the same stroke appears in a mocked Cosmos DB container. For load testing, k6 scripts can simulate 500 concurrent users drawing at 2 strokes per second, which typically stays under 30 ms latency on a Standard S2 App Service plan.

Conclusion

By marrying .NET 8 Minimal APIs, SignalR, and Azure Cosmos DB Change Feed, you get a highly responsive collaborative whiteboard that survives restarts, scales horizontally, and keeps cost predictable. The key takeaways are: persist inside the hub, let the Change Feed replay history, and monitor RU usage. With these patterns you can extend the same architecture to chat, live dashboards, or multiplayer games.

Sources

Microsoft Docs – SignalR for ASP.NET Core; Microsoft Docs – Azure Cosmos DB Change Feed; Azure Architecture Center – Real‑time collaboration patterns

Author: Mahmut Sarıkaya — sarikayadev.com

Tags: #real-time collaboration #.NET 8 #SignalR #Azure Cosmos DB #Change Feed
Share:
M

Written by

Mahmut Sarıkaya

Software Developer

Comments

No comments yet. Be the first to share your thoughts!

Leave a Comment

8 + 4 =