Real-time Communication
Real-time Communication
Section titled “Real-time Communication”Problem and Rationale
Section titled “Problem and Rationale”RednGift requires real-time updates for:
- Chat messages: Users communicate with their Secret Santa match anonymously
- Notifications: Group updates, new invitations, raffle completion alerts
- Presence: Show when match is typing or online
Context and Constraints
Section titled “Context and Constraints”- Bidirectional communication: Server must push updates without client polling
- Connection resilience: Auto-reconnect on network interruptions
- Authentication: WebSocket must validate user identity
- Scalability: Single connection per client (avoid multiple WebSocket instances)
- State synchronization: Integrate with Redux for message storage
Why WebSocket?
Section titled “Why WebSocket?”- Low latency: Near-instant message delivery (< 100ms)
- Efficiency: Single persistent connection vs. repeated HTTP requests
- Standard protocol: Native browser support, no third-party dependencies
- Server push: Backend initiates updates without client request
Why Singleton Pattern?
Section titled “Why Singleton Pattern?”- Resource management: One connection per user across all components
- State consistency: Shared message listeners and reconnection logic
- Memory efficiency: Avoid duplicate connections and event handlers
Design
Section titled “Design”WebSocket Client Singleton
Section titled “WebSocket Client Singleton”// src/services/handlers/websocket/WebSocketClient.ts (simplified)class WebSocketClient { private static instance: WebSocketClient; private connection: WebSocket | null = null; private messageListeners: MessageListener[] = []; private userId: number | null = null; private authToken: string | null = ''; private retries = 0; private MAX_RETRIES = 20; private WEBSOCKET_URL = 'wss://redngift-ws.rnb.la/connect';
private constructor() {}
public static getInstance(): WebSocketClient { if (!WebSocketClient.instance) { WebSocketClient.instance = new WebSocketClient(); } return WebSocketClient.instance; }
public init(userId: number): void { this.userId = userId; this.authToken = localStorage.getItem('accessToken'); if (this.authToken) this.connect(); }
private connect() { this.connection = new WebSocket( `${this.WEBSOCKET_URL}/${this.authToken}/` ); this.connection.onopen = () => this.handleOpen(); this.connection.onmessage = (message) => this.handleMessage(message); this.connection.onerror = (error) => this.handleError(error); this.connection.onclose = (event) => this.handleClose(event); }
public addMessageListener(listener: MessageListener): void { this.messageListeners.push(listener); }
private handleMessage(event: MessageEvent): void { this.messageListeners.forEach((listener) => listener(event)); }
public disconnect(loggedOut: boolean): void { if (loggedOut) { this.loggedOut = loggedOut; this.userId = null; this.authToken = null; } if (this.connection?.readyState === WebSocket.OPEN) { this.connection.close(); } }}
export default WebSocketClient;React Integration
Section titled “React Integration”// src/auth/context/web-socket/web-socket-context.tsx (simplified)'use client';
import { useEffect } from 'react';import { useAuthContext } from 'src/auth/hooks';import WebSocketClient from 'src/services/handlers/websocket/WebSocketClient';
export default function WebSocketProvider({ children }: Props) { const { user, authenticated } = useAuthContext();
useEffect(() => { if (authenticated && user?.id) { const wsClient = WebSocketClient.getInstance(); wsClient.init(user.id);
return () => { wsClient.disconnect(false); }; } }, [authenticated, user?.id]);
return <>{children}</>;}Wraps authenticated routes in src/app/(home)/layout.tsx:
<AuthGuard> <WebSocketProvider> <HomeLayout>{children}</HomeLayout> </WebSocketProvider></AuthGuard>Message Handler
Section titled “Message Handler”import { toast } from 'react-toastify';import { store } from 'src/utils/redux/store';import { addChatMessage } from 'src/utils/redux/slices/chatSlice';
export const handleWebSocketMessage = (event: MessageEvent) => { const message = JSON.parse(event.data);
switch (message.type) { case 'NEW_MESSAGE': store.dispatch(addChatMessage(message.payload)); toast.info(`New message from ${message.sender}`); break;
case 'GROUP_UPDATED': store.dispatch(fetchOneGroup(message.groupId)); toast.success('Group updated'); break;
case 'INVITATION_RECEIVED': toast.info('You have a new group invitation!'); break;
case 'RAFFLE_COMPLETE': toast.success('Secret Santa raffle completed!'); break;
default: console.warn('Unknown message type:', message.type); }};Register handler in WebSocketProvider:
useEffect(() => { const wsClient = WebSocketClient.getInstance(); wsClient.addMessageListener(handleWebSocketMessage);}, []);Lifecycle and Data Flow
Section titled “Lifecycle and Data Flow”1. Connection Establishment
Section titled “1. Connection Establishment”User logs in │ ▼AuthProvider sets user in context │ ▼WebSocketProvider detects authenticated user │ ▼Call WebSocketClient.getInstance().init(userId) │ ▼Read accessToken from localStorage │ ▼Create WebSocket connection URL: wss://redngift-ws.rnb.la/connect/{token}/ │ ├─ Success ─────────────────────┐ │ │ │ ▼ │ onopen event │ │ │ ▼ │ retries = 0 │ │ │ ▼ │ Connection ready │ └─ Error ───────────────────────┐ │ ▼ onerror event │ ▼ Log error │ ▼ Wait for onclose2. Message Reception
Section titled “2. Message Reception”Backend sends message │ ▼onmessage event fires │ ▼Call all registered listeners │ ├─ handleWebSocketMessage ──────┐ │ │ │ ▼ │ Parse JSON payload │ │ │ ▼ │ Switch on message.type │ │ │ ▼ │ Dispatch Redux action │ │ │ ▼ │ Show toast notification │ └─ Other listeners... ──────────┘3. Disconnection and Reconnection
Section titled “3. Disconnection and Reconnection”Connection closes (network drop, server restart) │ ▼onclose event fires │ ├─ User logged out? ────────────┐ │ │ │ ▼ │ Do NOT reconnect │ └─ Still authenticated ─────────┐ │ ▼ Check event.wasClean │ ├─ Clean close ─────────┐ │ │ │ ▼ │ reconnectImmediately() │ └─ Unexpected close ─────┐ │ ▼ reconnectWithBackoff() │ ▼ Retry #1: 0s delay Retry #2: 10s delay Retry #3: 20s delay ... Retry #20: 190s delay │ ▼ Max retries reached → Stop4. Logout Cleanup
Section titled “4. Logout Cleanup”User clicks logout │ ▼AuthProvider.logout() │ ▼WebSocketProvider useEffect cleanup │ ▼wsClient.disconnect(true) // loggedOut = true │ ▼Set loggedOut flag to prevent reconnection │ ▼Close WebSocket connection │ ▼Clear userId and authTokenImplementation Notes
Section titled “Implementation Notes”Patterns
Section titled “Patterns”✅ DO:
- Initialize WebSocket after authentication completes
- Use singleton pattern to prevent multiple connections
- Register message handlers in
useEffectwith cleanup - Dispatch Redux actions from message handlers to update UI
❌ DON’T:
- Don’t create WebSocket connections in components (use provider)
- Don’t send messages before
onopenfires (checkreadyState) - Don’t store WebSocket instance in Redux (not serializable)
- Don’t forget to cleanup listeners on component unmount
Connection State Management
Section titled “Connection State Management”Check readyState before sending:
public sendMessage(message: string): void { if (this.connection?.readyState === WebSocket.OPEN) { this.connection.send(message); } else { console.warn('WebSocket not connected'); toast.error('Cannot send message. Connection lost.'); }}WebSocket.readyState values:
0(CONNECTING): Connection not yet open1(OPEN): Ready to send/receive2(CLOSING): Connection closing3(CLOSED): Connection closed
Reconnection Strategy
Section titled “Reconnection Strategy”Immediate reconnect (clean close):
- Server intentionally closed connection (e.g., for maintenance)
- Attempt reconnect without delay
Backoff reconnect (unexpected close):
- Network error or server crash
- Exponential backoff: retry 1 at 0s, retry 2 at 10s, retry 3 at 20s, etc.
- Stop after 20 retries (~3 minutes) to avoid infinite loop
private reconnectWithBackoff() { if (this.retries < this.MAX_RETRIES) { this.retries += 1; const retryInterval = this.retries === 1 ? 0 : (this.retries - 1) * 10000; setTimeout(() => this.reconnect(), retryInterval); } else { console.error('Max retries reached. WebSocket connection failed.'); toast.error('Unable to connect to real-time server.'); }}Error Handling
Section titled “Error Handling”- Invalid token: Server rejects connection →
onerror+onclose→ User sees error toast - Network timeout: Browser fires
onerror→ Reconnection logic kicks in - Server crash:
onclosewithwasClean = false→ Backoff reconnection
Sentry integration:
private handleError(error: Event): void { console.error('WebSocket error:', error); Sentry.captureException(error, { tags: { component: 'WebSocket' }, extra: { userId: this.userId }, });}Performance Considerations
Section titled “Performance Considerations”- Message batching: Server can batch multiple updates into single message
- Ping/pong: Backend sends periodic heartbeat to detect dead connections
- Binary messages: Use
ArrayBufferfor large payloads (not implemented)
Optimization: Throttle high-frequency events (typing indicators):
const sendTypingIndicator = _.throttle(() => { wsClient.sendMessage(JSON.stringify({ type: 'TYPING' }));}, 1000);Testing Strategy
Section titled “Testing Strategy”Unit Tests
Section titled “Unit Tests”describe('WebSocketClient', () => { it('should be a singleton', () => { const instance1 = WebSocketClient.getInstance(); const instance2 = WebSocketClient.getInstance(); expect(instance1).toBe(instance2); });
it('should not reconnect after logout', () => { const wsClient = WebSocketClient.getInstance(); wsClient.disconnect(true); // Trigger onclose event wsClient['connection']?.close(); // Verify reconnect() not called expect(wsClient['retries']).toBe(0); });});Integration Tests
Section titled “Integration Tests”import { renderHook } from '@testing-library/react';import WebSocketProvider from './web-socket-context';
it('should initialize WebSocket on auth', () => { const mockUser = { id: 1 }; jest.spyOn(useAuthContext, 'useAuthContext').mockReturnValue({ authenticated: true, user: mockUser, });
const initSpy = jest.spyOn(WebSocketClient.prototype, 'init');
renderHook(() => <WebSocketProvider />);
expect(initSpy).toHaveBeenCalledWith(1);});E2E Tests
Section titled “E2E Tests”- Connect to WebSocket and verify
onopenfires - Send message from server, verify React component updates
- Disconnect network, verify reconnection attempts
- Logout, verify WebSocket closes and doesn’t reconnect
Assumption: Use mock WebSocket server (e.g., mock-socket library) for testing.
Extending This Concept
Section titled “Extending This Concept”Adding Binary Message Support
Section titled “Adding Binary Message Support”For sending images or files:
public sendBinaryMessage(data: ArrayBuffer): void { if (this.connection?.readyState === WebSocket.OPEN) { this.connection.send(data); }}
private handleMessage(event: MessageEvent): void { if (event.data instanceof ArrayBuffer) { // Handle binary data this.handleBinaryMessage(event.data); } else { // Handle JSON text data const message = JSON.parse(event.data); this.messageListeners.forEach((listener) => listener(message)); }}Adding Typing Indicators
Section titled “Adding Typing Indicators”Send typing events:
public sendTypingIndicator(conversationId: number): void { this.sendMessage(JSON.stringify({ type: 'TYPING', conversationId, }));}Handle incoming typing events:
case 'TYPING': store.dispatch(setUserTyping({ conversationId: message.conversationId, isTyping: true, })); break;Adding Presence (Online Status)
Section titled “Adding Presence (Online Status)”Backend broadcasts user online/offline:
case 'USER_ONLINE': store.dispatch(updateUserPresence({ userId: message.userId, online: true, })); break;
case 'USER_OFFLINE': store.dispatch(updateUserPresence({ userId: message.userId, online: false, })); break;Display in UI:
const isMatchOnline = useSelector((state) => state.presence[matchUserId]?.online);
return ( <div> {isMatchOnline && <Badge color="success">Online</Badge>} </div>);Adding Message Acknowledgment
Section titled “Adding Message Acknowledgment”Client confirms receipt:
private handleMessage(event: MessageEvent): void { const message = JSON.parse(event.data);
// Process message this.messageListeners.forEach((listener) => listener(message));
// Send ACK back to server this.sendMessage(JSON.stringify({ type: 'ACK', messageId: message.id, }));}Server tracks unacknowledged messages and resends on reconnect.
Further Reading
Section titled “Further Reading”- Real-time Chat - Chat feature using WebSocket
- Authentication - How JWT token is used for WebSocket auth
- State Management - Redux integration with WebSocket messages
- Error Handling - Sentry tracking for WebSocket errors