You've got a working Socket.io setup: clients connect, joinWhat is join?A SQL operation that combines rows from two or more tables based on a shared column, letting you query related data in one request. rooms, exchange messages. But a production app needs more than that. Messages need delivery confirmation. Unauthenticated users need to be blocked at the door. Spammers need to be throttled. And when you eventually run more than one server, your rooms need to stay in sync. That's what this lesson covers.
Acknowledgments
When a client emits a message, it's fire-and-forget by default, there's no built-in confirmation that the server received it and processed it successfully. Acknowledgments fix that with a callbackWhat is callback?A function you pass into another function to be called later, often when an operation finishes or an event occurs. pattern.
// CLIENT: emit with a callback (the acknowledgment)
socket.emit('send-message', { text: 'Hello!' }, (response) => {
if (response.ok) {
console.log('Message saved with ID:', response.id);
markMessageAsSent(response.id); // show a checkmark in the UI
} else {
console.error('Failed to send:', response.error);
showRetryOption();
}
});
// SERVER: receive and call the callback when done
socket.on('send-message', async (data, callback) => {
try {
const message = await db.messages.create({
text: data.text,
userId: socket.userId,
timestamp: new Date()
});
// Broadcast to the room (minus the sender)
socket.broadcast.to(socket.currentRoom).emit('new-message', message);
// Confirm success to the sender
callback({ ok: true, id: message.id });
} catch (error) {
callback({ ok: false, error: error.message });
}
});This is exactly how chat apps show "delivered" and "read" checkmarks. Without acknowledgments, you're guessing whether messages made it through.
socket.timeout(5000).emit('event', data, callback). If the server doesn't call the callback within 5 seconds, Socket.io automatically invokes it with an error. This makes it easy to build retry logic without hanging the UI indefinitely.AuthenticationWhat is authentication?Verifying who a user is, typically through credentials like a password or token. with middlewareWhat is middleware?A function that runs between receiving a request and sending a response. It can check authentication, log data, or modify the request before your main code sees it.
Your WebSocket server should not be an open door. Before accepting any connection, you need to verify the user is who they claim to be. Socket.io middleware runs once per connection attempt, before the connection event fires. Call next(new Error(...)) to reject the connection.
const jwt = require('jsonwebtoken');
// Middleware: verify JWT before allowing connection
io.use(async (socket, next) => {
try {
const token = socket.handshake.auth.token;
if (!token) {
return next(new Error('Authentication required'));
}
const payload = jwt.verify(token, process.env.JWT_SECRET);
const user = await db.users.findById(payload.userId);
if (!user) {
return next(new Error('User not found'));
}
// Attach user data to the socket for use in all handlers
socket.userId = user.id;
socket.username = user.username;
next(); // allow the connection
} catch {
next(new Error('Invalid token'));
}
});
// All handlers now have socket.userId and socket.username available
io.on('connection', (socket) => {
console.log(`${socket.username} connected`);
});On the client, send the tokenWhat is token?The smallest unit of text an LLM processes - roughly three-quarters of a word. API pricing is based on how many tokens you use. as part of the connection options:
const socket = io('http://localhost:3001', {
auth: {
token: localStorage.getItem('auth-token')
}
});
// Handle connection rejection
socket.on('connect_error', (error) => {
if (error.message === 'Authentication required') {
redirectToLogin();
}
});socket.emit() payloads. Use socket.handshake.auth, it's sent once during the handshake, not with every message. Embedding tokens in message payloads means every single event handler has to check authentication separately, which is error-prone and easy to forget.Error handling patterns
Unhandled exceptions inside Socket.io event handlers can crash your Node.js process. Wrap async handlers in try/catch and always respond through the acknowledgment callbackWhat is callback?A function you pass into another function to be called later, often when an operation finishes or an event occurs. when available:
// Utility: wrap async event handlers to catch unexpected errors
function asyncHandler(fn) {
return function (...args) {
Promise.resolve(fn.apply(this, args)).catch((err) => {
console.error('Unhandled socket error:', err);
const callback = args[args.length - 1];
if (typeof callback === 'function') {
callback({ ok: false, error: 'Internal server error' });
}
});
};
}
io.on('connection', (socket) => {
socket.on('send-message', asyncHandler(async (data, callback) => {
const message = await db.messages.create(data);
callback({ ok: true, id: message.id });
}));
});| Error scenario | Handling strategy |
|---|---|
| Invalid or missing payload fields | Validate before processing; callback with error |
| Database or external service failure | Catch, log, respond with error via callback |
| Unauthenticated user | Reject in middleware before connection is accepted |
| Unexpected exception in handler | Catch-all wrapper to prevent server crash |
Rate limitingWhat is rate limiting?Restricting how many requests a client can make within a time window. Prevents brute-force attacks and protects your API from being overwhelmed.
Without rate limiting, a single buggy or malicious client can emit thousands of events per second and bring your server to its knees. A simple per-socket limiter covers most cases:
function createRateLimiter(maxPerSecond) {
return function (socket, next) {
let count = 0;
const interval = setInterval(() => { count = 0; }, 1000);
const original = socket.onevent;
socket.onevent = function (packet) {
if (++count > maxPerSecond) {
socket.emit('error', { message: 'Too many requests. Slow down.' });
return;
}
original.call(this, packet);
};
socket.on('disconnect', () => clearInterval(interval));
next();
};
}
// Max 10 events per second per client
io.use(createRateLimiter(10));rate-limiter-flexible package works well with Socket.io and supports Redis out of the box.Scaling with multiple servers
A single Node.js server can handle thousands of WebSocket connections. But not millions. When you scale horizontally, multiple server instances behind a load balancerWhat is load balancer?A server that distributes incoming traffic across multiple backend servers so no single server gets overwhelmed., rooms break. A client on Server A joins a room; a client on Server B emits to that room. Server B doesn't know about it.
The fix is the Socket.io Redis adapter. It uses Redis as a shared message bus, so all server instances share room state automatically.
npm install @socket.io/redis-adapter ioredisconst { createAdapter } = require('@socket.io/redis-adapter');
const { createClient } = require('ioredis');
const pubClient = createClient({ host: 'localhost', port: 6379 });
const subClient = pubClient.duplicate();
io.adapter(createAdapter(pubClient, subClient));
// Rooms now work transparently across all server instancesWhen Server A emits to a room, Redis fans the message out to all other server instances, which deliver it to their connected clients in that room. From the application code's perspective, nothing changes.
Architecture comparison
| Setup | Approximate capacity | Rooms across servers | Complexity |
|---|---|---|---|
| Single server | ~10K–50K connections | N/A | Low |
| Multiple servers, no adapter | ~50K–500K connections | Broken | Medium |
| Multiple servers + Redis adapter | 500K+ connections | Works correctly | Medium-High |
Quick reference
| Pattern | Code |
|---|---|
| Emit with acknowledgment (client) | socket.emit('event', data, (res) => { }) |
| Acknowledge from server | socket.on('event', (data, callback) => { callback({ ok: true }) }) |
| Timeout on acknowledgment | socket.timeout(5000).emit('event', data, cb) |
| Auth middleware | io.use((socket, next) => { ... next() }) |
| Send auth token (client) | io({ auth: { token: '...' } }) |
| Reject a connection | next(new Error('Reason')) |
| Handle auth error (client) | socket.on('connect_error', handler) |
| Add Redis adapter | io.adapter(createAdapter(pub, sub)) |
// Production-ready Socket.io: auth middleware + acknowledgments + rate limiting
const express = require('express');
const http = require('http');
const { Server } = require('socket.io');
const jwt = require('jsonwebtoken');
const app = express();
const server = http.createServer(app);
const io = new Server(server, { cors: { origin: '*' } });
// ============ RATE LIMITER MIDDLEWARE ============
io.use((socket, next) => {
let count = 0;
const interval = setInterval(() => { count = 0; }, 1000);
const original = socket.onevent;
socket.onevent = function (packet) {
if (++count > 20) {
socket.emit('error', { message: 'Rate limit exceeded' });
return;
}
original.call(this, packet);
};
socket.on('disconnect', () => clearInterval(interval));
next();
});
// ============ AUTH MIDDLEWARE ============
io.use(async (socket, next) => {
try {
const token = socket.handshake.auth.token;
if (!token) return next(new Error('Authentication required'));
const payload = jwt.verify(token, process.env.JWT_SECRET);
socket.userId = payload.userId;
socket.username = payload.username;
next();
} catch {
next(new Error('Invalid token'));
}
});
// ============ CONNECTION HANDLERS ============
io.on('connection', (socket) => {
console.log(`✅ ${socket.username} connected`);
socket.on('join-room', (room, callback) => {
socket.join(room);
socket.currentRoom = room;
socket.to(room).emit('user-joined', { user: socket.username });
callback({ ok: true });
});
socket.on('send-message', async (data, callback) => {
try {
if (!data.text?.trim()) {
return callback({ ok: false, error: 'Message cannot be empty' });
}
const message = {
id: Date.now(),
user: socket.username,
text: data.text.trim(),
room: socket.currentRoom,
timestamp: new Date().toISOString()
};
socket.to(socket.currentRoom).emit('new-message', message);
callback({ ok: true, id: message.id, timestamp: message.timestamp });
} catch (err) {
console.error('Error handling message:', err);
callback({ ok: false, error: 'Internal server error' });
}
});
socket.on('disconnect', () => {
if (socket.currentRoom) {
socket.to(socket.currentRoom).emit('user-left', { user: socket.username });
}
console.log(`❌ ${socket.username} disconnected`);
});
});
server.listen(3001);
// ============ CLIENT USAGE ============
import { io } from 'socket.io-client';
const socket = io('http://localhost:3001', {
auth: { token: localStorage.getItem('auth-token') }
});
socket.on('connect_error', (err) => {
if (err.message === 'Authentication required') {
window.location.href = '/login';
}
});
socket.emit('join-room', 'general', (res) => {
if (res.ok) console.log('Joined room');
});
function sendMessage(text) {
socket.emit('send-message', { text }, (res) => {
if (res.ok) {
markAsSent(res.id);
} else {
showError(res.error);
}
});
}