Real-Time
ProBeya provides real-time updates so all users viewing the same board see changes instantly. This is implemented using WebSocket connections with Redis PubSub as the message broker.
Architecture
Next.js web -- publishes board updates --> Redis PubSub
|
Client A -- authenticated WebSocket --> ws --+-- room fan-out
Client B -- authenticated WebSocket --> ws --+
- Next.js verifies the user's tenant, membership, board, and view permission, then mints a short-lived board-scoped token.
- The client presents that token as a WebSocket subprotocol during the HTTP upgrade; identity is never accepted from a query parameter or message body.
- The Node.js
wsservice subscribes to Redis channels and pushes authorized updates to board rooms across instances. - Clients apply the update to their local state.
Connecting
Using Raw WebSocket
const boardId = "brd_abc123";
const tokenResponse = await fetch("/api/ws/token", {
method: "POST",
credentials: "same-origin",
cache: "no-store",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ boardId }),
});
if (!tokenResponse.ok) throw new Error("Board subscription denied");
const { token } = await tokenResponse.json();
// NEXT_PUBLIC_WS_URL in production, ws://localhost:8003 locally.
const ws = new WebSocket("wss://ws.probeya.com", ["probeya-v1", token]);
ws.onopen = () => {
ws.send(JSON.stringify({ type: "join_board", boardId }));
};
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
console.log("Event:", data);
};
The token is intentionally kept out of the URL so reverse-proxy access logs do not capture it. Reconnects must request a fresh token and therefore re-run ACL checks.
Event Types
Board Events
interface BoardUpdateEvent {
type: "board_update";
action: string;
data: Record<string, unknown>;
}
Item Events
type ItemAction =
| "item_created"
| "item_updated"
| "item_deleted"
| "item_moved";
Presence Events
interface PresenceEvent {
type: "presence";
boardId: string;
users: {
id: string;
name: string;
avatar?: string;
}[];
}
Client Messages
| Message | Description |
|---|---|
{ "type": "join_board", "boardId": "..." } | Join the one board authorized by the handshake token |
{ "type": "leave_board", "boardId": "..." } | Leave a board room |
{ "type": "ping" } | Keepalive request |
Optimistic Updates
The client applies mutations locally before the server confirms them. When the server event arrives, the client reconciles:
// 1. Apply optimistic update locally
updateLocalState({ itemId: "itm_abc123", status: "done" });
// 2. Send mutation to server
await client.item.update({ id: "itm_abc123", fields: { status: "done" } });
// 3. Server broadcasts event to all clients (including sender)
// 4. Client recognizes its own event and skips re-applying
This ensures the UI feels instant while maintaining consistency across all clients.
Connection Management
- Connections automatically reconnect on disconnect with exponential backoff.
- Heartbeat pings are sent every 30 seconds to detect stale connections.
- The server closes idle connections after 5 minutes of no subscriptions.