Course:Node.js & Express/
Lesson

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.

Good to know
Socket.io v4+ added timeout support for acknowledgments: 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.
02

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();
  }
});
Good to know
Never put tokens inside 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.
03

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 scenarioHandling strategy
Invalid or missing payload fieldsValidate before processing; callback with error
Database or external service failureCatch, log, respond with error via callback
Unauthenticated userReject in middleware before connection is accepted
Unexpected exception in handlerCatch-all wrapper to prevent server crash
04

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));
Good to know
For more granular control, per event type, with exponential backoff, or synchronized across multiple servers, use a Redis-backed rate limiter. The rate-limiter-flexible package works well with Socket.io and supports Redis out of the box.
05

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 ioredis
const { 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 instances

When 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

SetupApproximate capacityRooms across serversComplexity
Single server~10K–50K connectionsN/ALow
Multiple servers, no adapter~50K–500K connectionsBrokenMedium
Multiple servers + Redis adapter500K+ connectionsWorks correctlyMedium-High
06

Quick reference

PatternCode
Emit with acknowledgment (client)socket.emit('event', data, (res) => { })
Acknowledge from serversocket.on('event', (data, callback) => { callback({ ok: true }) })
Timeout on acknowledgmentsocket.timeout(5000).emit('event', data, cb)
Auth middlewareio.use((socket, next) => { ... next() })
Send auth token (client)io({ auth: { token: '...' } })
Reject a connectionnext(new Error('Reason'))
Handle auth error (client)socket.on('connect_error', handler)
Add Redis adapterio.adapter(createAdapter(pub, sub))
javascript
// 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);
    }
  });
}