How TREK Implements Real-Time Collaborative Sync Using WebSockets

TREK implements real-time collaborative sync using WebSockets by assigning authenticated clients to per-trip rooms on the server and broadcasting JSON events to all open sockets in that room, while the client automatically reconnects and re-hydrates state after dropped connections.

The open-source mauriceboe/TREK project demonstrates how to build low-latency, real-time collaborative sync using WebSockets without relying on external pub/sub services. Its architecture centers on a lightweight room manager that groups authenticated sockets by trip ID and pushes granular JSON events to every participant. By coupling this with a resilient browser client that re-fetches canonical state after network interruptions, TREK keeps multi-user trip data consistent in real time.

WebSocket Authentication and Connection Setup

Every real-time session begins with a short-lived token obtained from /api/auth/ws-token. The client opens a WebSocket to /ws?token=…, and the server validates the token—including MFA and password-version checks—before assigning a unique socket ID.

In server/src/websocket.ts (lines 60‑78), the server extracts and verifies the token. Once validated, it assigns the socket ID between lines 101‑108. This gatekeeping ensures only authorized users enter the WebSocket layer.

Room-Based Trip Isolation

After the handshake, the client sends a join message containing a tripId. The server checks canAccessTrip from server/src/db/database.ts to enforce authorization, then inserts the socket into a room map keyed by the numeric trip ID. Each socket also tracks its own memberships in a secondary socketRooms map.

According to the TREK source code in server/src/websocket.ts (lines 42‑55 and 49‑55), the message handler parses the join request and updates both maps:

// inside websocket.ts, message handler
if (msg.type === 'join' && msg.tripId) {
  const tripId = Number(msg.tripId)
  if (!canAccessTrip(tripId, user.id)) { … }
  rooms.get(tripId)?.add(nws)          // put socket into the room
  socketRooms.get(nws)?.add(tripId)    // remember the membership
}

This room model isolates traffic by trip, so clients only receive updates for the trips they are currently viewing.

Broadcasting Server Events to Clients

When a REST endpoint or service mutates trip data, it calls broadcast(tripId, event, payload, excludeSid?) to push the change in real time. As implemented in server/src/websocket.ts (lines 90‑103), the function iterates over the target room, skips closed sockets, optionally omits the originating socket, and sends a compact JSON message:

// server/src/websocket.ts
export function broadcast(
  tripId: number | string,
  eventType: string,
  payload: Record<string, unknown>,
  excludeSid?: number | string,
): void {
  const room = rooms.get(Number(tripId))
  if (!room) return

  const excludeNum = excludeSid ? Number(excludeSid) : null
  for (const ws of room) {
    if (ws.readyState !== 1) continue            // only OPEN sockets
    if (excludeNum && socketId.get(ws) === excludeNum) continue
    ws.send(JSON.stringify({ type: eventType, tripId, ...payload }))
  }
}

Because the broadcast happens immediately after database persistence, all connected clients stay in sync with the canonical server state.

Client-Side Event Dispatch

On the browser side, client/src/api/websocket.ts maintains a singleton WebSocket instance. Incoming messages flow through handleMessage (lines 67‑77), which parses the JSON payload and forwards it to every registered listener.

Clients register interest via addListener (lines 90‑96):

// client/src/api/websocket.ts
export function addListener(fn: WebSocketListener): void {
  listeners.add(fn)
}

// Somewhere in a component
addListener(event => {
  if (event.type === 'joined') console.log('Joined trip', event.tripId)
})

This pattern decouples the transport layer from UI components, letting the frontend reactively update when noteAdded, joined, or other server events arrive.

Resilience, Keep-Alive, and Reconnection

Real-world networks drop. TREK counters this with defensive mechanisms on both ends of the wire.

Server Heartbeat and Rate Limiting

The Node.js server defends against stale sockets and abuse. It emits a WebSocket ping every 30 seconds and terminates any socket that fails to respond with a pong. Additionally, each socket is capped at 30 messages per 10 seconds. These protections live in server/src/websocket.ts between lines 18‑30 (rate limit) and lines 49‑57 (heartbeat).

Auto-Reconnect and State Re-Hydration

The client singleton in client/src/api/websocket.ts manages its own retry loop via connectInternal and scheduleReconnect (lines 82‑94), using exponential back-off. Once the socket reopens, it re-joins every previously active trip and optionally invokes a refetch callback to pull the latest canonical data from the REST API (lines 111‑135):

socket.onopen = () => {
  // Re‑join all trips we were watching
  activeTrips.forEach(tripId => {
    socket?.send(JSON.stringify({ type: 'join', tripId }))
  })
  // Pull fresh data from the API
  refetchCallback?.(tripId)
}

This dual strategy—rejoining the WebSocket room plus refetching via HTTP—guarantees that clients recover both the live event stream and any data they missed while offline.

Putting It All Together: An End-to-End Flow

A typical real-time collaborative sync using WebSockets in TREK looks like this:

  1. The client obtains a token and calls connect() from client/src/api/websocket.ts.
  2. It requests trip updates with joinTrip('42').
  3. The server adds the socket to room 42 in server/src/websocket.ts.
  4. Another user adds a note through a REST endpoint, which triggers:
// e.g., a note service
import { broadcast } from '@/websocket'

export function addNote(tripId, note) {
  // …persist note in DB…
  broadcast(tripId, 'noteAdded', { note }, socketId) // push to all sockets
}
  1. Every open client in room 42 receives the event, handleMessage parses it, and registered listeners update the UI instantly.

Summary

  • TREK implements real-time collaborative WebSocket sync through a room-based architecture where each trip ID maps to an isolated set of sockets.
  • Authentication uses short-lived tokens validated at handshake time, including MFA and password-version checks in server/src/websocket.ts.
  • Broadcasting is handled by a lightweight broadcast() function that pushes JSON only to open sockets in the target room, with optional sender exclusion.
  • Client isolation is enforced by canAccessTrip before any socket enters a room, keeping trip data private.
  • Resilience is built in via server heartbeats, rate limiting, exponential back-off reconnect, and REST re-hydration callbacks.

Frequently Asked Questions

How does TREK authenticate WebSocket connections?

TREK issues a short-lived token from /api/auth/ws-token that the client passes when opening /ws?token=…. The server verifies this token in server/src/websocket.ts (lines 60‑78), performs MFA and password-version checks, and then assigns a unique socket ID before allowing further messages.

What prevents one client from receiving updates for another user's trip?

The server calls canAccessTrip inside the join message handler in server/src/websocket.ts (lines 42‑55). If the user is not authorized for the requested tripId, the socket is rejected from the room. Broadcasts are scoped solely to sockets inside that trip's room, so data never leaks across trips.

How does TREK handle a client that loses network connectivity?

The client manager in client/src/api/websocket.ts detects closures and schedules reconnections with exponential back-off via scheduleReconnect (lines 82‑94). Once the connection restores, the client automatically re-joins all active trips and runs a refetch callback (lines 111‑135) to merge any missed REST state back into the UI.

Why does TREK use both WebSocket broadcasts and REST refetching?

WebSocket broadcasts deliver low-latency, real-time updates to active clients, but a disconnected client can miss events. The optional refetch callback triggered after reconnect pulls the full, canonical state over HTTP, ensuring the client eventually converges to the correct data even after prolonged outages.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →