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:
  1. WebSocket Server — Hono running on Bun, handling persistent connections
  2. Redis Pub/Sub — Cross-instance message broadcasting for horizontal scaling
  3. 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.