Overview
Obeya Cloud delivers real-time collaboration through a dedicated WebSocket server. Changes propagate instantly to all connected clients — no polling required. The system handles live cursors, presence indicators, item updates, and collaborative editing.
Architecture
The real-time system consists of three components:
- WebSocket Server — Hono running on Bun, handling persistent connections
- Redis Pub/Sub — Cross-instance message broadcasting for horizontal scaling
- Client SDK — React hooks for consuming real-time events
Client A ──WebSocket──► WS Server 1 ──Redis Pub/Sub──► WS Server 2 ──WebSocket──► Client B
│ │
PostgreSQL PostgreSQL
(write events) (read events)
Connection Lifecycle
// Client-side connection (apps/web/src/lib/realtime.ts)
import { useEffect } from "react";
import { useRealtimeStore } from "@/stores/realtime";
export function useRealtimeConnection(boardId: string) {
const { connect, disconnect } = useRealtimeStore();
useEffect(() => {
const ws = connect({
url: `${process.env.NEXT_PUBLIC_WS_URL}/ws/${boardId}`,
token: getSessionToken(),
onMessage: (event) => {
const message = JSON.parse(event.data);
handleRealtimeMessage(message);
},
});
return () => disconnect(ws);
}, [boardId]);
}
Message Protocol
All WebSocket messages follow a typed JSON protocol:
// packages/validators/src/realtime.ts
type RealtimeMessage =
| { type: "item.created"; payload: { item: Item } }
| { type: "item.updated"; payload: { itemId: string; changes: Partial<Item> } }
| { type: "item.deleted"; payload: { itemId: string } }
| { type: "item.moved"; payload: { itemId: string; groupId: string; position: number } }
| { type: "value.set"; payload: { itemId: string; columnId: string; value: unknown } }
| { type: "cursor.move"; payload: { userId: string; position: { x: number; y: number } } }
| { type: "presence.join"; payload: { userId: string; user: UserInfo } }
| { type: "presence.leave"; payload: { userId: string } }
| { type: "typing.start"; payload: { userId: string; itemId: string; columnId: string } }
| { type: "typing.stop"; payload: { userId: string } };
Presence System
The presence system tracks which users are viewing each board:
// apps/ws/src/presence.ts
class PresenceManager {
private boards = new Map<string, Map<string, UserPresence>>();
join(boardId: string, userId: string, userInfo: UserInfo) {
const board = this.boards.get(boardId) ?? new Map();
board.set(userId, {
userId,
name: userInfo.name,
avatar: userInfo.avatar,
color: this.assignColor(board.size),
joinedAt: Date.now(),
cursor: null,
});
this.boards.set(boardId, board);
// Broadcast to all other users on this board
this.broadcast(boardId, {
type: "presence.join",
payload: { userId, user: userInfo },
}, userId);
}
leave(boardId: string, userId: string) {
this.boards.get(boardId)?.delete(userId);
this.broadcast(boardId, {
type: "presence.leave",
payload: { userId },
});
}
getOnlineUsers(boardId: string): UserPresence[] {
return Array.from(this.boards.get(boardId)?.values() ?? []);
}
}
Live Cursors
When the Visual Board is active, cursor positions are broadcast to all connected users:
// Client-side cursor tracking
function useLiveCursors(boardId: string) {
const ws = useWebSocket();
const cursors = useRealtimeStore((s) => s.cursors);
useEffect(() => {
const handleMouseMove = throttle((e: MouseEvent) => {
ws.send(JSON.stringify({
type: "cursor.move",
payload: {
position: { x: e.clientX, y: e.clientY },
},
}));
}, 50); // Throttle to 20fps
window.addEventListener("mousemove", handleMouseMove);
return () => window.removeEventListener("mousemove", handleMouseMove);
}, [ws]);
return cursors;
}
Cursor updates are throttled to 50ms (20 updates per second) to minimize bandwidth usage while maintaining smooth movement.
Optimistic Updates
The client applies changes optimistically before server confirmation:
// Using tRPC with optimistic updates
const utils = api.useUtils();
const updateItem = api.items.update.useMutation({
onMutate: async (newData) => {
// Cancel outgoing refetches
await utils.items.list.cancel();
// Snapshot previous value
const previous = utils.items.list.getData({ boardId });
// Optimistically update
utils.items.list.setData({ boardId }, (old) =>
old?.map((item) =>
item.id === newData.id ? { ...item, ...newData } : item
)
);
return { previous };
},
onError: (err, newData, context) => {
// Rollback on error
utils.items.list.setData({ boardId }, context?.previous);
},
onSettled: () => {
// Refetch to ensure consistency
utils.items.list.invalidate({ boardId });
},
});
Conflict Resolution
When two users edit the same field simultaneously, the server uses last-write-wins with version checks:
// Server-side conflict check
const current = await db.query.values.findFirst({
where: and(
eq(values.itemId, input.itemId),
eq(values.columnId, input.columnId),
),
});
if (current && current.version > input.expectedVersion) {
throw new TRPCError({
code: "CONFLICT",
message: "Value was modified by another user",
});
}
Scaling
The WebSocket server scales horizontally via Redis Pub/Sub:
- Each WS server instance manages its own connections
- When a message is published, it goes to Redis, which fans it out to all instances
- Each instance delivers the message to its connected clients
- Sticky sessions are not required
For production deployments, run at least 2 WebSocket server instances behind a load balancer for high availability.