Skip to content

Case: Real-Time Collab App

Problem

Design a collaborative document editor like Google Docs / Notion. Multiple users edit same doc concurrently. Presence (who's online, cursors). Changes propagate within ~100ms. Persistent history.

Walkthrough

Clarify

  • Up to 50 concurrent editors per doc.
  • Up to 100K active docs.
  • Tens of thousands of concurrent users globally.
  • Conflict resolution: ops merge automatically (CRDT or OT).
  • Offline editing → sync on reconnect.
  • Persistent history: undo/redo + audit.

High-level architecture

[Client (Browser SPA)]
  │ WebSocket (SignalR or Y.js native)
[Collab Hub — ASP.NET Core / SignalR]
  ├──→ [Redis backplane / Azure SignalR for fan-out across hub instances]
  ├──→ [Doc state store: Cosmos DB or Postgres + JSONB]
  └──→ [Operations log / event store: Cosmos change feed or Kafka]

[Background sync: persist debounced state]

CRDT vs OT

  • OT (Operational Transformation): ops transformed against concurrent ops; central server typical. Google Docs uses.
  • CRDT (Conflict-free Replicated Data Type): math-based; ops merge regardless of order. Y.js is the standard CRDT lib for collab.

For 2026 greenfield: CRDT (Y.js / Automerge) — simpler; offline-friendly; no central server logic.

Y.js + .NET backend

import * as Y from 'yjs';
import { WebsocketProvider } from 'y-websocket';

const ydoc = new Y.Doc();
const provider = new WebsocketProvider('wss://collab/ws', 'doc-123', ydoc);
const ytext = ydoc.getText('content');

Backend (Y.js doesn't dictate language; you can stand up a relay):

app.MapHub<CollabHub>("/collab");

public class CollabHub : Hub
{
    public Task UpdateDoc(string docId, byte[] update)
    {
        // Broadcast to all clients in this doc; persist update event
        return Clients.OthersInGroup(docId).SendAsync("Update", update);
    }
}

Or use existing Y.js relay servers. .NET hub primarily handles auth + persistence.

Presence

public class CollabHub(IPresenceTracker p) : Hub
{
    public async Task JoinDoc(string docId)
    {
        await Groups.AddToGroupAsync(Context.ConnectionId, docId);
        await p.AddAsync(docId, Context.UserIdentifier!);
        await Clients.Group(docId).SendAsync("Presence", await p.ListAsync(docId));
    }

    public override async Task OnDisconnectedAsync(Exception? ex)
    {
        // remove from all docs
    }
}

Cursor positions: ephemeral; broadcast at high frequency; not persisted.

Persistence

Strategy:
  - Stream operations to Kafka/Cosmos changefeed.
  - Background worker debounces (1s) → persists snapshot to DB.
  - Client connects → reads snapshot + replays recent ops since snapshot.

Snapshots avoid replaying thousands of ops on cold start.

Scaling SignalR

[Client] → [Front Door / LB] → [SignalR pod 1, 2, 3, ...]
                            [Azure SignalR Service or Redis backplane]

Azure SignalR offloads connections — your app emits messages; Azure handles the per-user connections.

Auth

  • JWT via query string on WebSocket upgrade.
  • Per-doc authorization: claim or per-doc ACL check on Join.
  • Optimistic local edits; server enforces auth on persist.

Offline editing

Y.js stores changes locally; reconnects sync. CRDT merges seamlessly.

Scalability math

50 concurrent / doc * 100K active docs = 5M connections (not all simultaneous).
Realistic: 50K concurrent connections.
SignalR Standard: 1K connections per server. Need 50 servers.
Or Azure SignalR Service: scales automatically.

Cost

  • Azure SignalR Service: ~$1.50 per unit per day; one unit = 1K concurrent.
  • Cosmos DB for state: RUs depend on edit rate.
  • Background workers cheap.

Latency

Client edit → SignalR → Hub → broadcast → other clients
              <-- ~30-80ms typical -->

Client applies optimistically; rollback on conflict (rare with CRDT).

Failure modes

  • Server crash: clients reconnect; SignalR auto-reconnect; state from DB + recent ops.
  • Network partition: clients edit locally; sync on reconnect via CRDT merge.
  • DB outage: in-flight edits buffered in Redis / queue; persist when DB recovers.

History / undo

Op log persisted. Undo: client sends inverse op (CRDT).

Audit

Every op logged with userId + timestamp.
Replay arbitrary point-in-time.

Trade-offs

Choice Why Trade-off
CRDT (Y.js) Offline; merge-friendly Larger state per doc
Azure SignalR Managed scale Cost per connection
Cosmos changefeed Operations log Eventual consistency
Snapshots Fast cold start Coordinate snapshot+ops

What we'd skip

  • Heavy OT machinery: CRDT is simpler.
  • Strong consistency: not needed for edits.

What we'd add for higher scale

  • Multi-region replication of doc state (Cosmos multi-master).
  • Smarter snapshot scheduling (LRU; warm-cache).
  • CDN for read-only docs.

Cross-references