How TREK's WebSocket Real-Time Synchronization Works Between Clients
TREK implements a room-based WebSocket layer that synchronizes trip data across clients by authenticating connections, managing per-trip rooms, and broadcasting JSON events to all participants while handling automatic reconnection and state rehydration.
TREK is an open-source trip management application that uses WebSocket real-time synchronization to keep multiple clients viewing the same trip updated simultaneously. The architecture relies on a room-based subscription model where authenticated sockets join per-trip rooms and receive targeted JSON events whenever data changes. This implementation, found in the mauriceboe/TREK repository, ensures low-latency updates while maintaining security through token-based authentication and access control checks.
Authenticating WebSocket Connections
Before a client can participate in real-time synchronization, it must obtain a short-lived WebSocket token from the /api/auth/ws-token endpoint. This token includes MFA and password-version validations to ensure the session is still valid. When the client opens a WebSocket connection to /ws?token=…, the server extracts and verifies this token in server/src/websocket.ts (lines 60-78).
Upon successful verification, the server creates a unique socket ID for the connection and stores the authenticated user context. This ID tracks the connection throughout its lifecycle and enables targeted exclusion during broadcasts. The assignment occurs at lines 101-108 in the same file, using the ephemeral token service defined in server/src/services/ephemeralTokens.ts.
// Conceptual flow based on server/src/websocket.ts
const token = extractTokenFromUrl(ws.url);
const user = await verifyToken(token); // Includes MFA checks
const socketId = generateUniqueId();
socketUserMap.set(ws, { userId: user.id, socketId });
Joining Trip Rooms
After authentication, the client sends a join message specifying the tripId it wants to synchronize. The server validates access permissions using the canAccessTrip function from server/src/db/database.ts before admitting the socket to the room.
The room management system uses two mappings for bidirectional tracking: a room map that stores sets of sockets keyed by trip ID, and a socket map that records which rooms each socket occupies. This allows efficient cleanup when connections drop. The logic resides in server/src/websocket.ts at lines 42-55.
// server/src/websocket.ts (lines 42-55)
if (msg.type === 'join' && msg.tripId) {
const tripId = Number(msg.tripId);
if (!canAccessTrip(tripId, user.id)) {
ws.close(1008, 'Unauthorized');
return;
}
// Add socket to room
if (!rooms.has(tripId)) rooms.set(tripId, new Set());
rooms.get(tripId).add(ws);
// Track socket's memberships
if (!socketRooms.has(ws)) socketRooms.set(ws, new Set());
socketRooms.get(ws).add(tripId);
}
Broadcasting Real-Time Updates
When any REST endpoint or service modifies trip data, it calls the broadcast function exported from server/src/websocket.ts (lines 90-103). This function iterates over all sockets in the specified trip room and pushes a JSON message containing the event type and payload.
The implementation includes an excludeSid parameter that prevents echoing updates back to the originating client, reducing unnecessary network traffic. The function only transmits to sockets with readyState === 1 (OPEN), ensuring no errors on closed connections.
// server/src/websocket.ts (lines 90-103)
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;
if (excludeNum && socketId.get(ws) === excludeNum) continue;
ws.send(JSON.stringify({ type: eventType, tripId, ...payload }));
}
}
Client-Side Event Handling
On the client side, client/src/api/websocket.ts manages the WebSocket singleton and event distribution. Incoming messages pass through handleMessage (lines 67-77), which parses the JSON and notifies all registered listeners.
Components register callbacks using addListener (lines 90-96) to receive specific event types such as noteAdded or tripUpdated. This decoupled approach allows multiple UI components to react to the same real-time synchronization stream without direct coupling to the transport layer.
// client/src/api/websocket.ts
const listeners = new Set<WebSocketListener>();
export function addListener(fn: WebSocketListener): void {
listeners.add(fn);
}
function handleMessage(event: MessageEvent) {
const parsed = JSON.parse(event.data);
listeners.forEach(fn => fn(parsed));
}
Resilience and Reconnection Strategy
TREK's WebSocket real-time synchronization includes robust recovery mechanisms for network interruptions. When a connection drops, the client enters an exponential backoff retry loop managed by scheduleReconnect (lines 82-94).
Upon reconnection, the client automatically re-joins all previously active trips and executes a refetch callback to pull the latest canonical state from the REST API. This ensures that any missed updates during the disconnection are reconciled before the client resumes real-time synchronization. The logic spans lines 111-135 in client/src/api/websocket.ts.
// client/src/api/websocket.ts (reconnection flow)
socket.onopen = () => {
// Re-join all active trips
activeTrips.forEach(tripId => {
socket?.send(JSON.stringify({ type: 'join', tripId }));
});
// Rehydrate state from canonical source
refetchCallback?.(tripId);
};
Server Protection Mechanisms
To prevent resource exhaustion, the server implements rate limiting and heartbeat checks. Each socket is capped at 30 messages per 10 seconds (lines 18-30 in server/src/websocket.ts). Exceeding this limit results in immediate termination.
Additionally, the server sends ping frames every 30 seconds and terminates sockets that fail to respond with a pong within the expected timeframe (lines 49-57). These mechanisms ensure that stale or malicious connections do not consume server resources indefinitely.
Summary
- Token-based authentication: Clients obtain short-lived WS tokens that undergo MFA and password-version validation before the socket opens at
server/src/websocket.ts. - Room-based architecture: Sockets join per-trip rooms using
canAccessTripauthorization fromserver/src/db/database.ts, with bidirectional tracking for efficient cleanup. - Targeted broadcasting: The
broadcastfunction pushes JSON events to all room participants while optionally excluding the originating socket viaexcludeSid. - Automatic recovery: Clients reconnect with exponential backoff, re-join previous rooms, and refetch canonical data to synchronize missed updates.
- Resource protection: Rate limiting (30 msg/10s) and 30-second heartbeats prevent abuse and ensure connection health.
Frequently Asked Questions
How does TREK authenticate WebSocket connections securely?
TREK requires clients to obtain a short-lived token from /api/auth/ws-token before connecting. This token undergoes MFA and password-version checks during the handshake in server/src/websocket.ts (lines 60-78), ensuring only valid, active sessions can establish WebSocket connections.
What happens when a client disconnects during a trip update?
The client implements automatic reconnection with exponential backoff via scheduleReconnect in client/src/api/websocket.ts. Once reconnected, it re-joins all active trips and executes a refetch callback (lines 111-135) to pull the latest state from the REST API, ensuring the client resynchronizes any missed updates.
How does the server prevent broadcast storms or abuse?
The server limits each socket to 30 messages per 10 seconds (lines 18-30) and terminates connections exceeding this threshold. Additionally, the broadcast function accepts an excludeSid parameter that prevents sending updates back to the originating client, reducing redundant network traffic.
Can the server exclude specific clients from receiving updates?
Yes. The broadcast function in server/src/websocket.ts accepts an optional excludeSid parameter. When provided, the function skips the socket matching that ID during iteration, allowing the server to suppress updates to the client that triggered the change or any other specific participant.
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:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →