Skip to main content

Real-Time Architecture

CivicPulse uses Socket.IO 4 for real-time communication between the backend and all connected clients. This enables live issue updates, in-app chat, typing indicators, and user presence β€” without polling.


Connection Architecture​

Socket.IO runs on the same HTTP server as the REST API on port 3001. There is no separate WebSocket service to manage.

Client Browser
β”‚
β”‚ HTTP upgrade (ws://)
β–Ό
Express HTTP Server (port 3001)
β”‚
β”œβ”€β”€ REST routes (/api/*)
└── Socket.IO engine
β”œβ”€β”€ /socket.io/ (namespace: default)
└── JWT auth middleware on every handshake

In cluster mode, the Socket.IO @socket.io/cluster-adapter is used so that events emitted from one worker are broadcast to clients connected to other workers.


Authentication​

Every WebSocket connection must authenticate at the handshake. Connections without a valid JWT are rejected β€” no events are processed and the socket is immediately disconnected.

Client β€” establishing an authenticated connection:

import { io } from 'socket.io-client';

const socket = io('http://localhost:3001', {
auth: {
token: sessionStorage.getItem('accessToken'),
},
transports: ['websocket'], // skip long-polling
reconnectionAttempts: 5,
reconnectionDelay: 2000,
});

socket.on('connect_error', (err) => {
if (err.message === 'Authentication failed') {
// access token may be expired β€” try refreshing
refreshAccessToken().then((newToken) => {
socket.auth.token = newToken;
socket.connect();
});
}
});

Server β€” auth middleware:

io.use((socket, next) => {
const token = socket.handshake.auth?.token;
if (!token) return next(new Error('Authentication failed'));
try {
const payload = jwt.verify(token, process.env.JWT_SECRET);
socket.user = payload; // attached to socket for use in event handlers
next();
} catch {
next(new Error('Authentication failed'));
}
});

Chat Rooms​

Chat threads are scoped per issue. Rooms are named using the pattern:

room:issue:<issueId>

Clients join a room when they open an issue detail view and leave when they navigate away:

// Join
socket.emit('join:issue', issueId);

// Leave
socket.emit('leave:issue', issueId);

Server-side:

socket.on('join:issue', (issueId) => {
socket.join(`room:issue:${issueId}`);
});

socket.on('leave:issue', (issueId) => {
socket.leave(`room:issue:${issueId}`);
});

Messages posted to a room are broadcast only to members of that room. Issue-level events (status changes, upvotes) are broadcast globally so the issues list stays up to date without requiring clients to join every room.


Event Reference​

Issue Events​

These events are broadcast to all connected clients (global namespace).

issue:created​

Emitted when a new civic issue is submitted.

{
"issueId": "iss_01HN5K...",
"title": "Broken streetlight on MG Road",
"city": "bangalore",
"category": "infrastructure",
"priority": "high",
"status": "open",
"upvotes": 0,
"reportedBy": "usr_01HN...",
"createdAt": "2025-01-15T10:30:00.000Z"
}

issue:updated​

Emitted when an issue's status, priority, or assignment changes.

{
"issueId": "iss_01HN5K...",
"status": "in_progress",
"assignedTo": "off_01HN...",
"updatedAt": "2025-01-15T11:00:00.000Z"
}

issue:upvoted​

Emitted when a user upvotes an issue.

{
"issueId": "iss_01HN5K...",
"upvotes": 42,
"upvotedBy": "usr_01HN..."
}

Chat Events​

These events are scoped to room:issue:<issueId>.

message:new​

Emitted when a message is posted to an issue thread.

{
"messageId": "msg_01HN...",
"issueId": "iss_01HN5K...",
"content": "I reported this to BBMP last week as well.",
"author": {
"userId": "usr_01HN...",
"displayName": "Priya S.",
"avatarUrl": "https://..."
},
"reactions": {},
"createdAt": "2025-01-15T10:35:00.000Z"
}

message:reaction​

Emitted when a reaction is added or removed from a message.

{
"messageId": "msg_01HN...",
"issueId": "iss_01HN5K...",
"reactions": {
"πŸ‘": 3,
"πŸ™": 1
}
}

Presence Events​

user:typing​

Emitted to the room when a user is composing a message. Clients should debounce this emission (emit after 300ms of inactivity, stop after 2s silence).

{
"issueId": "iss_01HN5K...",
"userId": "usr_01HN...",
"displayName": "Priya S.",
"isTyping": true
}

Client debounce pattern:

let typingTimeout;

function handleInputChange(value) {
setInputValue(value);
socket.emit('user:typing', { issueId, isTyping: true });
clearTimeout(typingTimeout);
typingTimeout = setTimeout(() => {
socket.emit('user:typing', { issueId, isTyping: false });
}, 2000);
}

presence:update​

Emitted to all clients when a user connects or disconnects.

{
"userId": "usr_01HN...",
"displayName": "Priya S.",
"online": true,
"city": "bangalore"
}

Reconnection Strategy​

The Socket.IO client is configured with exponential back-off reconnection:

const socket = io(WS_URL, {
reconnection: true,
reconnectionAttempts: 5, // give up after 5 attempts
reconnectionDelay: 1000, // start at 1s
reconnectionDelayMax: 10000, // cap at 10s
randomizationFactor: 0.5, // add jitter to avoid thundering herd
});

socket.on('reconnect', (attemptNumber) => {
console.log(`Reconnected after ${attemptNumber} attempts`);
// Re-join any rooms the user was in before disconnect
if (currentIssueId) {
socket.emit('join:issue', currentIssueId);
}
});

socket.on('reconnect_failed', () => {
// Show offline banner in UI
setConnectionStatus('offline');
});

On reconnection, clients must re-emit join:issue for any rooms they were participating in β€” room membership is not persisted across disconnections.