WebSocket connection lifecycle, reconnect with exponential backoff, auth token injection, heartbeat ping/pong, event routing, message format (event/tenant_id/version/payload/diff), offline queue, visibility API handling, and SyncManager bridge to DuckDB for the Motadata frontend. Use when generating real-time features, listing page subscriptions, or WebSocket infrastructure.
Real-time communication patterns for the event-driven frontend architecture.
For SyncManager, React hooks, and Provider setup, see reference/sync-and-hooks.md.
All messages from the backend follow this structure:
// src/frontend/types/websocket.ts
interface WebSocketEvent<T = Record<string, unknown>> {
/** Event name: "{resource}.{action}" e.g. "user.updated" */
event: string
/** Tenant isolation key */
tenant_id: string
/** Monotonically increasing version per resource */
version: number
/** ISO-8601 server timestamp */
timestamp: string
/** Full or partial entity payload */
payload: T
/** Field-level diff (optional, present on updates) */
diff?: Record<string, { old: unknown; new: unknown }>
}
/** Event actions */
type EventAction = 'created' | 'updated' | 'deleted'
/** System events (non-resource) */
interface HeartbeatEvent {
event: 'system.heartbeat'
timestamp: string
}
interface AckEvent {
event: 'system.ack'
subscription_id: string
}
// src/frontend/services/websocket-manager.ts
type ConnectionState = 'disconnected' | 'connecting' | 'connected' | 'reconnecting'
interface WebSocketManagerConfig {
url: string
getAuthToken: () => string | null
heartbeatIntervalMs?: number // default: 30000
maxReconnectDelayMs?: number // default: 30000
}
type EventHandler = (event: WebSocketEvent) => void
type ConnectionHandler = (state: ConnectionState) => void
class WebSocketManager {
private ws: WebSocket | null = null
private state: ConnectionState = 'disconnected'
private reconnectAttempt = 0
private heartbeatTimer: ReturnType<typeof setInterval> | null = null
private reconnectTimer: ReturnType<typeof setTimeout> | null = null
private readonly handlers = new Map<string, Set<EventHandler>>()
private readonly connectionHandlers = new Set<ConnectionHandler>()
private readonly offlineQueue: WebSocketEvent[] = []
private isTabVisible = true
private readonly config: Required<WebSocketManagerConfig>
constructor(config: WebSocketManagerConfig) {
this.config = {
heartbeatIntervalMs: 30_000,
maxReconnectDelayMs: 30_000,
...config,
}
document.addEventListener('visibilitychange', this.handleVisibilityChange)
window.addEventListener('online', this.handleOnline)
window.addEventListener('offline', this.handleOffline)
}
connect(): void {
if (this.state === 'connected' || this.state === 'connecting') return
const token = this.config.getAuthToken()
if (!token) return
this.setState('connecting')
const url = new URL(this.config.url)
url.searchParams.set('token', token)
this.ws = new WebSocket(url.toString())
this.ws.onopen = this.handleOpen
this.ws.onclose = this.handleClose
this.ws.onerror = this.handleError
this.ws.onmessage = this.handleMessage
}
disconnect(): void {
this.clearTimers()
if (this.ws) {
this.ws.onclose = null
this.ws.close(1000, 'Client disconnect')
this.ws = null
}
this.setState('disconnected')
this.reconnectAttempt = 0
}
subscribe(eventPattern: string, handler: EventHandler): () => void {
if (!this.handlers.has(eventPattern)) {
this.handlers.set(eventPattern, new Set())
}
this.handlers.get(eventPattern)!.add(handler)
return () => {
this.handlers.get(eventPattern)?.delete(handler)
if (this.handlers.get(eventPattern)?.size === 0) {
this.handlers.delete(eventPattern)
}
}
}
onConnectionChange(handler: ConnectionHandler): () => void {
this.connectionHandlers.add(handler)
return () => { this.connectionHandlers.delete(handler) }
}
getState(): ConnectionState { return this.state }
destroy(): void {
this.disconnect()
document.removeEventListener('visibilitychange', this.handleVisibilityChange)
window.removeEventListener('online', this.handleOnline)
window.removeEventListener('offline', this.handleOffline)
this.handlers.clear()
this.connectionHandlers.clear()
this.offlineQueue.length = 0
}
// --- Private handlers (see full implementation for details) ---
// handleOpen: setState('connected'), resetAttempt, startHeartbeat, flushQueue
// handleClose: clearTimers, scheduleReconnect (unless code 1000)
// handleMessage: parse JSON, route system events, queue if tab hidden
// routeEvent: exact match → wildcard "resource.*" → global "*"
}
export { WebSocketManager }
export type { WebSocketManagerConfig, ConnectionState, EventHandler, ConnectionHandler }
Exponential Backoff with Jitter:
Attempt 1: ~1s (1000ms ± 250ms)
Attempt 2: ~2s (2000ms ± 500ms)
Attempt 3: ~4s (4000ms ± 1000ms)
Attempt 4: ~8s (8000ms ± 2000ms)
Attempt 5: ~16s (16000ms ± 4000ms)
Attempt 6+: ~30s (capped at maxReconnectDelayMs)
Reset to 0 on successful connection.
Pause when navigator.onLine === false.
Resume immediately when online event fires.
// Token refresh on reconnect
const wsManager = new WebSocketManager({
url: import.meta.env.VITE_WS_URL,
getAuthToken: () => {
const token = authStore.getAccessToken()
if (!token || authStore.isTokenExpired(token)) {
authStore.refreshToken()
return null // Returning null prevents connection attempt
}
return token
},
})
// NEVER: Store tokens in WebSocket URL permanently
const ws = new WebSocket(`wss://api.example.com?token=${token}`)
// ^ Token leaks in server logs. Backend should validate once on upgrade, then discard.
// NEVER: Reconnect without backoff
ws.onclose = () => { this.connect() } // BAD — hammers server
// NEVER: Skip version check before applying diffs
// NEVER: Process events while tab is hidden (expensive re-renders)
// GOOD — queue events, flush on visibility change
// NEVER: Create multiple WebSocket connections
// GOOD — single WebSocketManager instance, route events via subscribe()
// NEVER: Put WebSocket state in Redux
// GOOD — use useSyncExternalStore with WebSocketManager
// NEVER: Forget to unsubscribe on component unmount
Creating multiple WebSocket instances — WebSocketManager must be a singleton. Creating per-component connections causes connection storms and duplicate events. Use WebSocketProvider + useWebSocketManager() hook instead.
Storing connection state in Redux — Connection state is transient, not app state. Use useSyncExternalStore with WebSocketManager.getState() instead. Putting it in Redux causes unnecessary re-renders across the entire app.
Processing events while tab is hidden — Events received when document.visibilityState === 'hidden' should be queued, not processed. Processing triggers React re-renders that the user cannot see, wasting CPU. Flush the queue when tab becomes visible.
Forgetting to unsubscribe on component unmount — Every subscribe() call returns an unsubscribe function. If not called on unmount, handlers accumulate and fire for destroyed components, causing memory leaks and errors. The useWebSocketEvent hook handles this automatically via useEffect cleanup.
Skipping version check before applying diffs — Always compare event.version against the current sync_metadata.version. Applying stale or duplicate events corrupts the local DuckDB data. If the version gap exceeds the threshold, trigger a full re-sync instead.