CRDT: Collaborative Presence
Build real-time collaborative features — active users, typing indicators, and cursor tracking — using Grove CRDT presence.
This guide walks through building collaborative presence features using the Grove CRDT presence system. You'll add active user avatars, typing indicators, and cursor tracking to a React application backed by a Go server.
Prerequisites
- A Grove Go server with CRDT sync endpoints (see Getting Started)
- The
@grove-js/crdtTypeScript package installed - React 18+
Step 1: Enable Presence on the Server
Add WithPresenceEnabled(true) to your SyncController configuration:
app.Use(groveext.New(
groveext.WithDriver(pgdb),
groveext.WithCRDT(plugin, hook.Scope{Tables: []string{"documents"}}),
groveext.WithSyncController(
crdt.WithStreamPollInterval(500 * time.Millisecond),
crdt.WithStreamKeepAlive(15 * time.Second),
crdt.WithPresenceEnabled(true), // Enable presence
crdt.WithPresenceTTL(30 * time.Second), // Entry TTL (default: 30s)
),
))This registers two additional endpoints alongside the existing sync routes:
| Method | Path | Description |
|---|---|---|
| POST | /sync/presence | Update or leave a topic |
| GET | /sync/presence?topic=... | Get current presence snapshot |
The SSE stream at GET /sync/stream also broadcasts "presence" events.
Step 2: Configure the React Client
Enable presence in your CRDTProvider config:
import { CRDTProvider } from "@grove-js/crdt/react";
function App() {
return (
<CRDTProvider config={{
baseURL: "/api/sync",
nodeID: getNodeID(),
tables: ["documents"],
presence: { heartbeatInterval: 10000 }, // Re-send presence every 10s
}}>
<DocumentEditor />
</CRDTProvider>
);
}
function getNodeID(): string {
let id = sessionStorage.getItem("crdt-node-id");
if (!id) {
id = `browser-${crypto.randomUUID().slice(0, 8)}`;
sessionStorage.setItem("crdt-node-id", id);
}
return id;
}The heartbeatInterval controls how often the client re-sends presence to keep the server's TTL alive. Set it lower than the server's WithPresenceTTL (e.g., 10s heartbeat with 30s TTL).
Step 3: Active Users Component
Show who else is viewing a document using useDocumentPresence:
import { useEffect } from "react";
import { useDocumentPresence } from "@grove-js/crdt/react";
interface UserPresence {
name: string;
color: string;
avatar?: string;
}
const COLORS = ["#3b82f6", "#ef4444", "#10b981", "#f59e0b", "#8b5cf6", "#ec4899"];
function ActiveUsers({ docId }: { docId: string }) {
const { others, updateMyPresence } = useDocumentPresence<UserPresence>(
"documents",
docId,
);
// Announce ourselves when the component mounts.
useEffect(() => {
const color = COLORS[Math.floor(Math.random() * COLORS.length)];
updateMyPresence({
name: getCurrentUserName(),
color,
});
}, []);
return (
<div className="active-users">
{others.map((peer) => (
<div
key={peer.node_id}
className="avatar"
style={{ backgroundColor: peer.data.color }}
title={peer.data.name}
>
{peer.data.name[0].toUpperCase()}
</div>
))}
{others.length === 0 && (
<span className="solo">Only you</span>
)}
</div>
);
}The hook automatically sends a "leave" event when the component unmounts.
Step 4: Typing Indicators
Add a typing indicator to a text editor:
import { useCallback, useRef } from "react";
import { useDocumentPresence } from "@grove-js/crdt/react";
interface EditorPresence {
name: string;
color: string;
isTyping: boolean;
}
function CollaborativeTextarea({ docId }: { docId: string }) {
const { others, updateMyPresence } = useDocumentPresence<EditorPresence>(
"documents",
docId,
);
const typingTimeout = useRef<ReturnType<typeof setTimeout>>();
const handleTyping = useCallback(() => {
updateMyPresence({ isTyping: true });
// Clear previous timeout and set a new one.
clearTimeout(typingTimeout.current);
typingTimeout.current = setTimeout(() => {
updateMyPresence({ isTyping: false });
}, 2000);
}, [updateMyPresence]);
const typingUsers = others.filter((p) => p.data.isTyping);
return (
<div>
<textarea
onKeyDown={handleTyping}
rows={6}
placeholder="Start typing..."
/>
{typingUsers.length > 0 && (
<div className="typing-indicator">
{typingUsers.map((p) => p.data.name).join(", ")}{" "}
{typingUsers.length === 1 ? "is" : "are"} typing...
</div>
)}
</div>
);
}The typing state is set to true on each keystroke and automatically cleared after 2 seconds of inactivity.
Step 5: Cursor Tracking
Track cursor positions on a canvas or editor:
import { useCallback } from "react";
import { useDocumentPresence } from "@grove-js/crdt/react";
interface CursorPresence {
name: string;
color: string;
cursor: { x: number; y: number } | null;
}
function CursorOverlay({ docId }: { docId: string }) {
const { others, updateMyPresence } = useDocumentPresence<CursorPresence>(
"documents",
docId,
);
const handleMouseMove = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
const rect = e.currentTarget.getBoundingClientRect();
updateMyPresence({
cursor: {
x: e.clientX - rect.left,
y: e.clientY - rect.top,
},
});
},
[updateMyPresence],
);
const handleMouseLeave = useCallback(() => {
updateMyPresence({ cursor: null });
}, [updateMyPresence]);
return (
<div
className="cursor-area"
onMouseMove={handleMouseMove}
onMouseLeave={handleMouseLeave}
style={{ position: "relative" }}
>
{/* Render remote cursors */}
{others
.filter((p) => p.data.cursor)
.map((p) => (
<div
key={p.node_id}
className="remote-cursor"
style={{
position: "absolute",
left: p.data.cursor!.x,
top: p.data.cursor!.y,
pointerEvents: "none",
}}
>
<svg width="16" height="16" viewBox="0 0 16 16">
<path d="M0 0L16 6L8 8L6 16Z" fill={p.data.color} />
</svg>
<span
className="cursor-label"
style={{ backgroundColor: p.data.color }}
>
{p.data.name}
</span>
</div>
))}
{/* Your editor content here */}
</div>
);
}Step 6: Putting It All Together
Combine active users, typing indicators, and cursor tracking in a single document view:
import { CRDTProvider, useDocument, useDocumentPresence } from "@grove-js/crdt/react";
import { useEffect, useCallback, useRef } from "react";
interface DocPresence {
name: string;
color: string;
isTyping: boolean;
cursor: { x: number; y: number } | null;
}
function DocumentView({ docId }: { docId: string }) {
const { data, update } = useDocument<{ id: string; title: string; body: string }>(
"documents",
docId,
);
const { others, updateMyPresence } = useDocumentPresence<DocPresence>(
"documents",
docId,
);
const typingTimeout = useRef<ReturnType<typeof setTimeout>>();
useEffect(() => {
updateMyPresence({
name: getCurrentUserName(),
color: "#3b82f6",
isTyping: false,
cursor: null,
});
}, []);
const handleTyping = useCallback(() => {
updateMyPresence({ isTyping: true });
clearTimeout(typingTimeout.current);
typingTimeout.current = setTimeout(() => {
updateMyPresence({ isTyping: false });
}, 2000);
}, [updateMyPresence]);
if (!data) return <div>Loading...</div>;
const typingUsers = others.filter((p) => p.data.isTyping);
return (
<div>
{/* Active users */}
<div className="toolbar">
{others.map((p) => (
<span
key={p.node_id}
className="avatar"
style={{ backgroundColor: p.data.color }}
title={p.data.name}
>
{p.data.name[0]}
</span>
))}
</div>
{/* Title */}
<input
value={data.title}
onChange={(e) => update("title", e.target.value)}
/>
{/* Body with typing indicator */}
<textarea
value={data.body}
onChange={(e) => update("body", e.target.value)}
onKeyDown={handleTyping}
/>
{typingUsers.length > 0 && (
<p>
{typingUsers.map((p) => p.data.name).join(", ")}{" "}
{typingUsers.length === 1 ? "is" : "are"} typing...
</p>
)}
</div>
);
}Production Considerations
Debounce Cursor Updates
Cursor movement fires many events per second. Throttle updates to ~50ms:
import { useCallback, useRef } from "react";
function useThrottledPresence<T>(updateFn: (data: Partial<T>) => void, ms = 50) {
const lastUpdate = useRef(0);
const pending = useRef<Partial<T> | null>(null);
const timer = useRef<ReturnType<typeof setTimeout>>();
return useCallback(
(data: Partial<T>) => {
const now = Date.now();
if (now - lastUpdate.current >= ms) {
lastUpdate.current = now;
updateFn(data);
} else {
pending.current = data;
clearTimeout(timer.current);
timer.current = setTimeout(() => {
if (pending.current) {
updateFn(pending.current);
pending.current = null;
lastUpdate.current = Date.now();
}
}, ms);
}
},
[updateFn, ms],
);
}Keep Presence Data Small
Presence updates are sent frequently via HTTP POST and broadcast to all connected clients. Keep the data payload small:
- Send only what changed (the client merges partial updates)
- Avoid large objects or arrays in presence data
- Use numeric coordinates instead of complex position objects
Reconnection
When the SSE connection drops and reconnects, the client's presence entry may have expired on the server. The CRDTProvider handles this automatically — the heartbeat timer re-announces presence after reconnection.
Server-Side TTL Tuning
| Scenario | Heartbeat | TTL | Notes |
|---|---|---|---|
| Low-latency collaboration | 5s | 15s | More frequent checks, faster detection of disconnects |
| Standard collaboration | 10s | 30s | Good balance of responsiveness and overhead |
| High-scale (many clients) | 20s | 60s | Reduce server load at the cost of slower disconnect detection |
The TTL should be at least 2-3x the heartbeat interval to tolerate occasional network delays.
Next Steps
- Presence & Awareness for the full API reference
- TypeScript Client for the pluggable architecture and all React hooks
- SSE Streaming for how presence events flow over SSE