Socket.io: чат на Node.js и что он добавляет к WebSocket

✓ Все примеры выполнены на Socket.io 4.8.3, Node.js v22.23.1 — июль 2026

Содержание

Socket.io — самый частый ответ на «сделать чат на Node». Разберём, что он даёт поверх чистого WebSocket и где расставлены грабли.

Весь код выполнен на Socket.io 4.8.3, сервер и клиент — в одном процессе.

Это не WebSocket

Первое, что нужно понять: Socket.io — не реализация WebSocket, а собственный протокол поверх него.

Следствие сразу практическое: клиент Socket.io не подключится к серверу ws, а браузерный new WebSocket() не подключится к Socket.io. Они говорят на разных языках, хотя транспорт снизу тот же.

клиент подключён, id: -vf97L…
транспорт: websocket

Оговорка к популярному мифу: часто пишут, что Socket.io «сначала подключается через polling, потом апгрейдится до WebSocket». В нашем замере из Node клиент сразу пошёл по WebSocket — апгрейда не было. Polling остаётся запасным вариантом для окружений, где WebSocket не проходит (старый прокси, корпоративный файрвол), и в браузере поведение может отличаться. Именно этот откат — одна из причин брать Socket.io.

Сервер и клиент

import { createServer } from 'node:http';
import { Server } from 'socket.io';

const httpServer = createServer();
const io = new Server(httpServer);

io.on('connection', (socket) => {
  console.log('подключился', socket.id);

  socket.on('chat', (msg) => {
    io.emit('chat', msg);        // всем
  });
});

httpServer.listen(3000);

Socket.io цепляется к обычному HTTP-серверу — тому же, что отдаёт страницы. Отдельный порт не нужен.

Модель — события: emit шлёт, on слушает, на обеих сторонах одинаково.

emit и broadcast: разница, которую путают

socket.on('chat', (msg) => io.emit('chat', msg));              // всем
socket.on('private', (msg) => socket.broadcast.emit('chat', msg)); // всем, кроме автора
io.emit           -> A получил: [ 'привет из клиента A' ]
socket.broadcast  -> A получил: []                       <- пусто
                     B получил: [ 'это broadcast от A' ]

io.emit — от лица сервера всем подключённым, включая отправителя. Для чата это обычно и нужно: автор видит своё сообщение в общем потоке.

socket.broadcast.emit — всем, кроме этого сокета. Нужен, когда автор уже отрисовал сообщение у себя локально и дубль ему не нужен, или для событий вроде «пользователь печатает».

Кому уйдёт сообщение

io.emit('chat', msg)

От лица сервера — всем

  • Отправитель получит тоже
  • Обычный выбор для чата
  • Автор видит своё сообщение в потоке

socket.broadcast.emit(...)

Всем, кроме этого сокета

  • Отправитель не получит
  • «Пользователь печатает…»
  • Когда автор уже отрисовал у себя

io.to('room').emit(...)

Только участникам комнаты

  • Остальные не увидят ничего
  • Приватные чаты, каналы
  • Комната создаётся сама при join

Комнаты

socket.on('join', (room) => {
  socket.join(room);
  io.to(room).emit('chat', `в комнате ${room} новый участник`);
});

socket.on('room-msg', ({ room, text }) => {
  io.to(room).emit('chat', text);
});
B (в комнате vip) : [ 'в комнате vip новый участник', 'сообщение только для комнаты vip' ]
A (не в комнате)  : []   <- пусто, комната изолирует

Комната — просто строка. Создавать её не нужно: она появляется при первом join и исчезает, когда последний участник вышел. Каждый сокет автоматически состоит в комнате со своим socket.id — поэтому io.to(someSocketId).emit(...) работает как личное сообщение.

⚠️ Комнаты живут в памяти одного процесса. Запустили второй инстанс за балансировщиком — и он про эти комнаты ничего не знает. Нужен @socket.io/redis-adapter, который синхронизирует их через Redis pub/sub. Это первое, обо что спотыкаются при масштабировании.

Подтверждения: RPC поверх событий

// сервер
socket.on('need-answer', (payload, ack) => {
  ack('ответ сервера на ' + payload);
});

// клиент
const answer = await a.emitWithAck('need-answer', 'вопрос');
emitWithAck вернул: ответ сервера на вопрос

Третий аргумент обработчика — функция подтверждения. Вызвали её — и emitWithAck на клиенте разрешил промис. Получается запрос-ответ поверх событийной модели: удобно, когда нужно узнать, что сервер принял сообщение.

Главная ловушка: socket.id меняется

console.log('connected до обрыва :', a.connected);
a.io.engine.close();          // рвём соединение
// ...ждём автопереподключение
connected до обрыва : true
connected сразу после: false
переподключился сам : true | новый id: MfXyyn…

Клиент вернулся сам — и с другим id. Автопереподключение это то, ради чего Socket.io и берут: писать его руками поверх чистого WebSocket муторно.

Но отсюда правило, которое нарушают постоянно: не привязывайте состояние к socket.id. Хранили «пользователь Аня = сокет abc123» — после обрыва сети Аня вернулась как xyz789, и ваша таблица врёт.

Привязывайте к своему идентификатору пользователя, а socket.id считайте временным. Проверка это подтверждает: сервер за прогон увидел три подключения, хотя клиентов было два — третье и есть переподключившийся A.

подключился -vf97L…
подключился Bj_qbF…
подключился MfXyyn…   <- это снова A после обрыва
всего сокетов сейчас: 2

Пространства имён: не то же самое, что комнаты

Их постоянно путают, а разница принципиальная.

const admin = io.of('/admin');       // пространство имён
admin.on('connection', (socket) => {
  socket.join('reports');            // комната внутри пространства
});

Пространство имён (/admin) — это отдельная точка входа со своими обработчиками и своим middleware. Клиент подключается к нему явно: ioClient(url + '/admin'). Это способ разделить логику: у админки свои события, у чата свои, и они не пересекаются.

Комната — это группа внутри пространства имён. Клиент в неё не подключается, его туда добавляет сервер через join. Это способ разделить аудиторию.

Пространство имён Комната
Кто решает клиент (подключается сам) сервер (socket.join)
Зачем разделить логику и middleware разделить аудиторию
Своё соединение нет, транспорт общий нет
Аутентификация своя на каждое общая

Важная деталь: разные пространства имён не создают новых TCP-соединений — они мультиплексируются поверх одного. Так что бояться «лишних подключений» не нужно.

Аутентификацию удобно вешать именно на пространство имён:

admin.use((socket, next) => {
  const token = socket.handshake.auth.token;
  isAdmin(token) ? next() : next(new Error('нет доступа'));
});

Socket.io или чистый WebSocket

Нужно Брать
Двусторонний канал, переподключение пишете сами ws
Комнаты, автопереподключение, подтверждения Socket.io
Только сервер → клиент SSE, не сокеты вообще
Несколько инстансов Socket.io + redis-adapter

Цена Socket.io — свой протокол (несовместимость с чистым WebSocket) и лишний вес на клиенте. Плата за то, что переподключение, комнаты и откат на polling уже написаны.

Что было на этой странице в 2017 году

Первая версия вышла 30 июля 2017 года и называлась «Создание чата с нуля. Часть 1» — с Express в связке.

Что изменилось по существу:

  • emitWithAck не было. Подтверждения существовали, но только колбэком: socket.emit('event', data, (ответ) => {...}). Промис-версия появилась в 4-й ветке, и с ней подтверждение стало обычным await.
  • Express был обязателен по привычке. Сегодня видно, что Socket.io цепляется к любому HTTP-серверу — встроенного node:http достаточно, если вы не раздаёте статику.
  • Про несколько инстансов не думали. В 2017-м чат обычно жил на одной машине, и вопрос «комнаты в памяти процесса» не всплывал. Сейчас это первое, обо что спотыкаются.

Что не изменилось совсем: модель emit/on и разница между io.emit и broadcast. Код чата из той статьи концептуально работает и сегодня — поменялись версии, а не идея.


Смежные темы: как устроен WebSocket — что именно Socket.io прячет; Redis — нужен для комнат на нескольких инстансах; HTTP-сервер — к чему цепляется Socket.io. Полный список — в справочнике по Node.js.

Частые вопросы

Socket.io — это WebSocket?
Нет, это библиотека поверх WebSocket со своим протоколом. Поэтому клиент Socket.io не подключится к чистому WebSocket-серверу, и наоборот: браузерный new WebSocket() к серверу Socket.io тоже не подключится.
Чем `io.emit` отличается от `socket.broadcast.emit`?
io.emit шлёт всем, включая отправителя. socket.broadcast.emit — всем, кроме него. Проверено: при broadcast автор сообщения не получил ничего.
Можно ли хранить состояние по `socket.id`?
Нет. При переподключении id меняется — мы это замерили. Привязывайте состояние к своему идентификатору пользователя, а socket.id считайте временным.
Нужен ли Socket.io, если есть встроенный WebSocket?
Если нужен только двусторонний канал и вы готовы сами писать переподключение — хватит ws. Socket.io берут ради комнат, автопереподключения, подтверждений и отката на polling.
Работает ли Socket.io на нескольких серверах?
Только с адаптером. По умолчанию комнаты живут в памяти одного процесса, и на втором инстансе о них не знают. Нужен @socket.io/redis-adapter или аналог.