Building Collaborative Applications with SignalR

August 13, 2026Building Collaborative Applications with SignalR
Sarah Dutkiewicz

Sarah Dutkiewicz, Senior Trainer

In part 2 of our SignalR for Blazor series, we looked at connections and hubs, and we touched on a powerful tool for targeting: groups. Groups are how you aim a message at a set of related connections instead of everyone. In this post, we’re going to put groups to work by building a real collaborative application: a shared text editor where multiple users can type in the same document and see each other’s edits appear in real time. We’ll walk through the scenario, the architecture, and a complete, commented code example — including some error handling.

Scenario Introduction 🧑‍🤝‍🧑

Imagine a small team that needs to write up a shared document together — maybe meeting notes, a design spec, or a blog outline. In a traditional setup, they’d take turns or deal with the painful merging that comes from two people editing at once. What they really want is the experience of something like Google Docs: everyone opens the same document, everyone can type at the same time, and each person’s edits show up for everyone else the moment they happen.

Let’s break that down into the pieces a developer needs to think about:

  • Context: One shared document, potentially viewed by several users at once. Users who aren’t looking at a given document should never see its edits.
  • Roles: In a full-featured app you’d have editors (who can type) and viewers (who can only read). For our example we’ll treat everyone who joins as an editor, but we’ll mention how you’d enforce read-only access.
  • Desired behavior: When a user joins, they see the current contents of the document. When a user types, their changes are sent to the server and pushed to everyone else viewing that same document — immediately, without refreshing. A user’s own typing isn’t echoed back to them, since their screen already shows it.

Here’s what that interaction looks like:

  sequenceDiagram
    participant A as User A's browser
    participant Hub as DocumentHub (server)
    participant Store as DocumentStore
    participant B as User B's browser
    A->>Hub: JoinDocumentAsync("shared-doc")
    Hub->>Store: GetContent("shared-doc")
    Store-->>Hub: current text
    Hub-->>A: DocumentLoaded(text)
    B->>Hub: JoinDocumentAsync("shared-doc")
    Hub-->>B: DocumentLoaded(text)
    A->>Hub: UpdateDocumentAsync("shared-doc", "new text")
    Hub->>Store: SetContent("shared-doc", "new text")
    Hub-->>B: DocumentUpdated("new text")
    Note over A: User A's own edit is not echoed back to them
  

The key ingredients here are the ones we learned about in part 2: a hub to coordinate, groups to scope messages to one document, and a connection for each browser tab.

Implementation Overview 🏗️

The architecture is small, but it has three moving pieces that need to work together:

  • DocumentStore (server, singleton): holds the current text for each document in memory, so a late-joining user gets up to speed and the server is the source of truth.
  • DocumentHub (server): handles joining and editing. It puts each connection into a group named after the document, and it broadcasts edits only to the connections in that document’s group.
  • CollaborativeEditor (client): a Blazor component that opens a HubConnection, joins a document, sends typing updates, and applies updates that arrive from the server.
  flowchart LR
    subgraph Server [Server]
        direction TB
        H[DocumentHub] --> S[(DocumentStore)]
    end
    subgraph Group1 [shared-doc group]
        A[User A]
        B[User B]
    end
    subgraph Group2 [design-doc group]
        C[User C]
        D[User D]
    end
    A <--> H
    B <--> H
    C <--> H
    D <--> H
  

Because each document gets its own group, edits for shared-doc only reach the users viewing shared-doc. Users in design-doc never hear about them — exactly the isolation we wanted.

Client-side

The component’s job is to keep the textarea and the server in sync. When the user types, the component sends the full document text to the hub. When a DocumentUpdated event arrives from the server, the component replaces its local text with the received content. We use a one-way value binding plus an @oninput handler so the flow is explicit: every keystroke triggers a send, and every incoming update re-renders the textarea.

Server-side

The hub has two methods. JoinDocumentAsync adds the caller’s connection to the document’s group and sends back the current contents. UpdateDocumentAsync saves the new text to the store and broadcasts to everyone else in the group using Clients.OthersInGroup(...) — not the caller, since the caller already has the text on screen.

An important detail: group membership is scoped to the connection and is cleaned up automatically. When a browser tab closes or the connection drops, SignalR removes that connection from all its groups. We don’t have to write any cleanup logic.

Code Example (Detailed) 👨‍💻

Let’s build it. First, the shared store. Create a file called DocumentStore.cs:

using System.Collections.Concurrent;

namespace SignalRForBlazorDemo.Services;

public class DocumentStore
{
    // Keyed by document ID; stores the latest text for each document.
    private readonly ConcurrentDictionary<string, string> _documents = new();

    public string GetContent(string documentId) =>
        _documents.GetValueOrDefault(documentId, string.Empty);

    public void SetContent(string documentId, string content) =>
        _documents[documentId] = content;
}

We register it as a singleton so every hub invocation shares the same store:

builder.Services.AddSingleton<DocumentStore>();

Next, the hub. Create a file called DocumentHub.cs:

using Microsoft.AspNetCore.SignalR;
using SignalRForBlazorDemo.Services;

namespace SignalRForBlazorDemo.Hubs;

public class DocumentHub(DocumentStore store) : Hub
{
    public async Task JoinDocumentAsync(string documentId)
    {
        // Validate the input so clients get a clear error instead of a mystery.
        if (string.IsNullOrWhiteSpace(documentId))
        {
            throw new HubException("A document ID is required.");
        }

        // Add this connection to the group named after the document. Group
        // membership is scoped to the connection and is cleaned up by SignalR
        // when the connection closes, so we don't track it ourselves.
        await Groups.AddToGroupAsync(Context.ConnectionId, documentId);

        // Send the latest content to the caller so they start where others left off.
        string content = store.GetContent(documentId);
        await Clients.Caller.SendAsync("DocumentLoaded", content);
    }

    public async Task UpdateDocumentAsync(string documentId, string content)
    {
        // The server is the source of truth, so save the latest content. Future
        // joiners (and users who reconnect) will get this text.
        store.SetContent(documentId, content);

        // Broadcast to everyone else in the document's group, but NOT back to the
        // caller - their textarea already shows the text they just typed.
        await Clients.OthersInGroup(documentId).SendAsync("DocumentUpdated", content);
    }
}

Register the hub in Program.cs alongside the ones from the earlier posts:

app.MapHub<DocumentHub>("/documenthub");

Finally, the component. Create a file called CollaborativeEditor.razor:

@using Microsoft.AspNetCore.SignalR.Client
@implements IAsyncDisposable

<input value="@_documentId" @oninput="OnDocumentIdInput" />
<button @onclick="JoinDocumentAsync">Join Document</button>

@if (_errorMessage is not null)
{
    <p class="text-red-600">@_errorMessage</p>
}

<textarea value="@_content" @oninput="HandleInputAsync" rows="12" cols="90"
          placeholder="Type here... edits appear for others in real time."
          disabled="@(!_joined)"></textarea>

@code {
    private HubConnection? _connection;
    private string _documentId = "shared-doc";
    private string _content = string.Empty;
    private string? _errorMessage;
    private bool _joined;

    protected override async Task OnInitializedAsync()
    {
        // Build the connection to our DocumentHub.
        _connection = new HubConnectionBuilder()
            .WithUrl("/documenthub")
            .WithAutomaticReconnect()
            .Build();

        // The server pushes the document's contents after we join.
        _connection.On<string>("DocumentLoaded", content =>
        {
            _content = content;
            _joined = true;
            _errorMessage = null;
            StateHasChanged();
        });

        // The server pushes someone else's edits.
        _connection.On<string>("DocumentUpdated", content =>
        {
            _content = content;
            StateHasChanged();
        });

        await _connection.StartAsync();
    }

    private void OnDocumentIdInput(ChangeEventArgs e)
    {
        _documentId = e.Value?.ToString() ?? string.Empty;
    }

    private async Task JoinDocumentAsync()
    {
        if (_connection is null)
        {
            return;
        }

        try
        {
            await _connection.InvokeAsync("JoinDocumentAsync", _documentId);
        }
        catch (HubException ex)
        {
            // A HubException thrown on the server reaches the client with its
            // original message intact, so we can show it directly to the user.
            _errorMessage = $"Could not join the document: {ex.Message}";
        }
    }

    private async Task HandleInputAsync(ChangeEventArgs e)
    {
        // Update the local text immediately so typing feels responsive.
        _content = e.Value?.ToString() ?? string.Empty;

        if (_connection is null || !_joined)
        {
            return;
        }

        try
        {
            // Send the whole document text to the server, which broadcasts it
            // to the other users in the same document group.
            await _connection.SendAsync("UpdateDocumentAsync", _documentId, _content);
        }
        catch (HubException ex)
        {
            _errorMessage = $"Could not send the update: {ex.Message}";
        }
    }

    public async ValueTask DisposeAsync()
    {
        // Always clean up the connection when the component goes away.
        if (_connection is not null)
        {
            await _connection.DisposeAsync();
        }
    }
}

A note on the error handling 🛡️

There are two places errors can show up, and both are worth handling:

  • Server → client: when a hub method throws a HubException, SignalR sends its message to the calling client. That’s why JoinDocumentAsync throws HubException("A document ID is required.") for bad input, and it’s why the component catches HubException specifically. (Other exception types get a generic message unless you enable detailed errors, so HubException is a nice way to surface a friendly message on purpose.)
  • Client → server: the try/catch around the InvokeAsync and SendAsync calls keeps a failed network call or a rejected request from crashing the component — it just shows a message instead.

Discussion of Scalability Considerations 🚀

Our example is intentionally simple, but let’s talk about what happens when it grows:

  • More documents, not more problems. Groups scale naturally here. Every document is just a group, so the server can host many documents without any extra coordination. The hard part would be many users in the same document — every keystroke means a broadcast to everyone in that group.
  • Sending the whole text is wasteful. Each keystroke sends the entire document contents to the server and then to every other participant. That’s fine for small documents and a few users, but it gets expensive fast. Real collaborative editors send changes (diffs, or even conflict-resolution operations like Operational Transformation (OT) or CRDTs) instead of the whole document. That’s well beyond the scope of this post, but it’s the right direction when you outgrow this pattern.
  • Last-write-wins. In our example, if two people type at nearly the same moment, the server just keeps the most recent update it received. Real-time collaborative editing is hard precisely because of this: without conflict resolution, simultaneous edits clobber each other. For many business apps, “last write wins” is acceptable — just know the tradeoff.
  • Scaling across servers. Our store lives in memory on a single server, and groups are tracked per-server. When you deploy to multiple servers, you’ll want the Azure SignalR Service or a Redis backplane so connection and group information is shared, and you’ll want your document state in a shared store (a database or cache) rather than per-server memory.

What’s Next? 🎬

We built a collaborative editor that scopes real-time updates to groups of users, and along the way we put part 2’s concepts — connections, hubs, and groups — to practical use. In the next post, we’ll shift from users collaborating with each other to the server keeping users informed: real-time data updates like live dashboards and feeds, where the server pushes fresh data to clients the moment it changes.

Resources 📚