Skip to content

Real-time Communication

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
  • 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
  • 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
  • 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
// 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;
// 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>
src/services/handlers/websocket/MessageHandler.ts
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);
}, []);
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 onclose
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... ──────────┘
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 → Stop
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 authToken

DO:

  • Initialize WebSocket after authentication completes
  • Use singleton pattern to prevent multiple connections
  • Register message handlers in useEffect with 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 onopen fires (check readyState)
  • Don’t store WebSocket instance in Redux (not serializable)
  • Don’t forget to cleanup listeners on component unmount

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 open
  • 1 (OPEN): Ready to send/receive
  • 2 (CLOSING): Connection closing
  • 3 (CLOSED): Connection closed

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.');
}
}
  • Invalid token: Server rejects connection → onerror + onclose → User sees error toast
  • Network timeout: Browser fires onerror → Reconnection logic kicks in
  • Server crash: onclose with wasClean = 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 },
});
}
  • Message batching: Server can batch multiple updates into single message
  • Ping/pong: Backend sends periodic heartbeat to detect dead connections
  • Binary messages: Use ArrayBuffer for large payloads (not implemented)

Optimization: Throttle high-frequency events (typing indicators):

const sendTypingIndicator = _.throttle(() => {
wsClient.sendMessage(JSON.stringify({ type: 'TYPING' }));
}, 1000);
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);
});
});
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);
});
  • Connect to WebSocket and verify onopen fires
  • 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.

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));
}
}

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;

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>
);

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.