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.