Real-Time Collaborative Interfaces: Architecture and UX
Build real-time collaborative features — presence indicators, live cursors, conflict resolution, and the architecture decisions that make multi-user editing work.
Strategic Systems Architect & Enterprise Software Developer
Real-time collaboration has moved from a differentiating feature to a baseline expectation. Users have internalized the experience of Google Docs, Figma, and Notion — they expect to see other people's changes immediately, see who is online, and never lose their work to a conflict. Building that experience requires coordinating state across multiple clients, handling network failures gracefully, and designing UX patterns that make concurrent editing feel natural rather than chaotic.
This is one of the most technically challenging areas in frontend development, and the architecture decisions made early determine whether the system scales or collapses under real usage.
Presence and Awareness
The simplest real-time feature — and the one that provides the most immediate value — is presence. Showing which users are currently viewing or editing a document gives collaborators context about who might be affected by their changes.
Presence is lightweight to implement. Each client sends a heartbeat to the server via WebSocket, and the server broadcasts the current user list to all connected clients.
// composables/usePresence.ts
export function usePresence(documentId: string) {
const users = ref<PresenceUser[]>([])
const ws = useWebSocket(`/ws/presence/${documentId}`)
// Send heartbeat every 30 seconds
const heartbeat = setInterval(() => {
ws.send(JSON.stringify({ type: 'heartbeat' }))
}, 30_000)
ws.onMessage((event) => {
const message = JSON.parse(event.data)
if (message.type === 'presence') {
users.value = message.users
}
})
onUnmounted(() => {
clearInterval(heartbeat)
ws.close()
})
return { users: readonly(users) }
}
Display presence as avatar stacks near the document title. Color-code each user consistently — the same user should always appear as the same color across sessions. This color follows them into cursor positions and selection highlights, building a visual language users learn unconsciously.
The heartbeat interval determines how quickly disconnected users disappear. Too short (5 seconds) and users blink in and out during brief network interruptions. Too long (60 seconds) and dead sessions linger. Thirty seconds with a server-side timeout of 45 seconds works well for most applications.
Live Cursors and Selections
Showing other users' cursor positions and text selections is what makes collaboration feel truly live. Each client tracks its cursor position and broadcasts it through the same WebSocket connection used for presence.
function trackCursor(editor: EditorInstance) {
editor.on('selectionChange', (selection) => {
ws.send(JSON.stringify({
type: 'cursor',
position: selection.anchor,
selection: selection.head !== selection.anchor
? { from: selection.from, to: selection.to }
: null,
}))
})
}
Rendering remote cursors requires a rendering layer that draws colored carets and selection highlights without interfering with the local editing experience. In rich text editors built on ProseMirror or TipTap, decorations handle this cleanly. In simpler inputs, absolutely positioned elements overlaid on the text area work but require careful position calculation.
Throttle cursor broadcasts to avoid overwhelming the WebSocket connection. Sending every cursor movement creates excessive traffic during rapid typing. Throttling to every 50-100 milliseconds provides smooth visual updates without saturating the connection. The receiving clients can interpolate cursor positions between updates for smoother animation.
Conflict Resolution Strategies
The hard problem in real-time collaboration is conflict resolution. When two users edit the same part of a document simultaneously, the system must produce a consistent result on both clients without losing either user's changes.
Last-write-wins is the simplest strategy and works for independent fields — if two users change a document title simultaneously, one wins. This is acceptable when conflicts are rare and the stakes are low. Store-level state management tools like Pinia can handle this with simple WebSocket update handlers.
Operational Transformation (OT) is the classic approach used by Google Docs. Each edit is represented as an operation (insert "hello" at position 5, delete 3 characters at position 12). When operations from different clients arrive concurrently, the server transforms them against each other to produce a consistent result. OT is well-understood but complex to implement correctly — the transformation functions for rich text operations are notoriously difficult to get right.
CRDTs (Conflict-free Replicated Data Types) are the modern alternative. CRDTs guarantee that concurrent operations always converge to the same result without requiring a central server to coordinate. Libraries like Yjs and Automerge implement CRDTs for text, arrays, maps, and other data structures:
import * as Y from 'yjs'
import { WebsocketProvider } from 'y-websocket'
Const ydoc = new Y.Doc()
const provider = new WebsocketProvider('ws://localhost:1234', 'document-id', ydoc)
Const ytext = ydoc.getText('content')
// Changes propagate automatically to all connected clients
ytext.insert(0, 'Hello, collaborators')
CRDTs have a significant advantage over OT: they work peer-to-peer. The server is a relay and persistence layer, not a coordination point. This means the system degrades gracefully when the server is slow or temporarily unavailable — clients continue editing locally and sync when connectivity returns.
The trade-off is that CRDTs use more memory than OT because they track the history needed for conflict resolution. For document-scale collaboration, this is rarely a practical problem. For collaboration on large data structures — say, a spreadsheet with millions of cells — memory usage needs monitoring.
Handling Network Failures
Network failures are not edge cases in collaboration — they are the normal operating condition. Mobile users move between WiFi and cellular. Hotel internet drops every few minutes. Users close their laptop lids and reopen them hours later.
The UX for disconnected state must communicate clearly without being alarmist. A subtle indicator showing "Reconnecting..." is appropriate. A modal dialog that blocks editing is not. Users should always be able to continue editing locally during disconnection.
<template>
<div class="flex items-center gap-2 text-sm">
<span
:class="connected ? 'bg-green-500' : 'bg-amber-500'"
class="h-2 w-2 rounded-full"
/>
<span v-if="!connected" class="text-neutral-500">
Reconnecting — your changes are saved locally
</span>
</div>
</template>
When the connection restores, the sync process should happen automatically and silently. With CRDTs, this is inherent — the library handles merging divergent states. With OT or custom sync, you need to queue operations during disconnection and replay them on reconnection, handling any server-side changes that occurred while the client was offline.
Autosave is expected in collaborative applications. Users do not think about saving when collaborating because the mental model is "everyone sees my changes immediately." If changes can be lost, the collaboration promise is broken. Save state to IndexedDB as a fallback for browser crashes, and sync to the server on every meaningful change. The performance impact of frequent saves is negligible with efficient diff-based persistence.
Building real-time collaboration well is hard. But the patterns are established, the libraries are mature, and the user expectations are clear. Start with presence, add live cursors, and layer in conflict resolution complexity only as your use case demands it.