websockets-reconnect.md — vim

WebSocket в проде: всё, что приходится дописать вокруг одной строчки

new WebSocket(url) отлично работает на демо. Что к нему добавляется, когда чат должен пережить метро, лифт и сон ноутбука.


new WebSocket(url) - одна строчка. На демо работает идеально.

Потом ты выкатываешь онлайн-поддержку в прод, и выясняется, что вокруг этой строчки надо написать ещё четыре механизма. Иначе оператор пишет клиенту в мёртвое соединение и не понимает, почему тот молчит.

Соединение рвётся всегда

Не «может порваться», а рвётся: вайфай переключился на LTE, ноутбук ушёл в сон, прокси прибил простаивающее соединение, бэкенд выкатил релиз. Браузер сам не переподключается, это наша работа.

Только не в лоб «раз в секунду». Если бэкенд лёг, тысяча клиентов начнёт его долбить каждую секунду и не даст подняться. Нужна экспоненциальная задержка со случайным разбросом - про jitter хорошо написано у AWS: без него все клиенты синхронизируются и приходят волнами.

let attempt = 0;

function scheduleReconnect() {
  const base = Math.min(1000 * 2 ** attempt, 30_000); // 1с, 2с, 4с... до 30
  const delay = base * (0.5 + Math.random() * 0.5);   // разброс 50-100%
  attempt++;
  setTimeout(connect, delay);
}

function connect() {
  const ws = new WebSocket(url);

  ws.onopen = () => { attempt = 0; flushQueue(ws); };

  ws.onclose = (e) => {
    if (e.code === 1000) return;  // закрыли сами, всё нормально
    scheduleReconnect();
  };
}

Мёртвое соединение выглядит как живое

Самый неприятный случай из всех. Формально readyState === OPEN, а данные не ходят - такое бывает, когда обрыв произошёл так, что FIN до нас не дошёл. Клиент уверен, что всё хорошо, и молчит часами.

Поэтому пингуем сами:

let pongTimer: number;

function startHeartbeat(ws: WebSocket) {
  setInterval(() => {
    if (ws.readyState !== WebSocket.OPEN) return;
    ws.send(JSON.stringify({ type: 'ping' }));
    pongTimer = window.setTimeout(() => ws.close(4000, 'no pong'), 5000);
  }, 25_000);
}

// в обработчике сообщений
if (msg.type === 'pong') clearTimeout(pongTimer);

25 секунд взяты не с потолка: прокси и балансировщики закрывают неактивные соединения примерно через минуту (у nginx это дефолт), так что пинг должен быть заметно чаще.

Сообщения, отправленные в разрыв

Пользователь жмёт «отправить» ровно в момент реконнекта. ws.send() уходит в никуда, сообщение пропало, а человек уверен, что написал. Поэтому весь исходящий трафик идёт через очередь:

const queue: string[] = [];

function send(data: unknown) {
  const payload = JSON.stringify(data);
  if (ws?.readyState === WebSocket.OPEN) ws.send(payload);
  else queue.push(payload);
}

function flushQueue(ws: WebSocket) {
  while (queue.length) ws.send(queue.shift()!);
}

Вторая половина той же проблемы - сообщения, которые пришли, пока нас не было. Сокет их не восстановит, тут нужен обычный REST: при переподключении дозапрашиваем историю с последнего известного id. Это и есть честный ответ на «а почему после обрыва пропали сообщения».

Состояние надо показывать

Если чат не работает, человек должен это видеть, а не догадываться. Три состояния хватает:

type ConnectionState = 'connecting' | 'online' | 'offline';

Серая плашка «подключение…» и «нет связи, пробуем ещё раз». Обращений в поддержку из-за «у вас всё сломалось» после этого стало заметно меньше - люди просто ждут пару секунд.

Наружу отдаём composable

Компонентам не надо знать про очереди и реконнекты:

const { state, messages, send } = useChatSocket(roomId);

Внутри всё вышеописанное плюс onUnmounted(() => socket.close(1000)), чтобы соединение не жило после ухода с экрана. Здесь composable честно выигрывает у сервиса-синглтона: жизнь соединения привязана к жизни экрана, и забыть закрыть его сложнее.

И последнее. Если данные идут только от сервера к клиенту - уведомления, прогресс задачи, статусы - возьми лучше SSE. Браузер переподключается сам, работает поверх обычного HTTP, не требует отдельной возни с прокси. WebSocket нужен, когда трафик двусторонний и частый, во всех остальных случаях это лишняя сложность.

Ссылки