---
title: "Reconnect: клиент, который возвращается"
description: "Websocket reconnect на клиенте: экспоненциальный backoff с setTimeout и джиттером, повторные подписки после обрыва и resume с последним seq. Код на JS и Go."
course: "WebSocket в Go: real-time чаты и уведомления"
url: "https://golangmaster.ru/courses/websocket/lessons/17-reconnect/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 18
---

# Reconnect: клиент, который возвращается

*Курс «WebSocket в Go: real-time чаты и уведомления», урок 17 из 25. ~18 минут. Обновлено 21.09.2026. Источник: https://golangmaster.ru/courses/websocket/lessons/17-reconnect/*

Websocket reconnect — это поведение вкладки после обрыва: соединение рвётся в лифте, в тоннеле и на каждом деплое, и вопрос не в том, как этого избежать, а в том, как возвращаться вежливо. Здесь соберём экспоненциальный backoff с джиттером, разберём повторные подписки на новом сокете и покажем, как клиент догоняет пропущенное через resume с последним seq.

Соединение, которое живёт часами, гарантированно рвётся: телефон уходит в сон, Wi-Fi переключается на LTE, прокси убивает простой, а сервер из прошлого урока честно прощается кадром 1001 на каждом релизе. Дизайн-вопрос не «как не рвать» — никак, — а «что делает вкладка в следующие десять секунд». Плохих ответа два. Первый: ничего — пользователь смотрит на замёрзший чат и перезагружает страницу сам, теряя всё состояние интерфейса. Второй: мгновенный бесконечный цикл переподключений — при недоступном сервере вкладка штампует попытки без паузы, греет процессор клиента и добивает сервер, когда тот начнёт подниматься. Правильный ответ посередине: паузы растут с каждой неудачей, попытки прекращаются там, где бессмысленны, а после успеха клиент восстанавливает то, что слушал раньше. Переподключение клиента — это не одна строка с new WebSocket в retry-цикле, а маленькая политика с тремя правилами.

### Websocket reconnect: экспоненциальный backoff по шагам

Ядро политики — экспоненциальный backoff: пауза между попытками удваивается с каждой неудачей, от базового значения до потолка. Схема для чата: база одна секунда, потолок тридцать — паузы идут одна, две, четыре, восемь, шестнадцать, тридцать, тридцать. Зачем именно так: сервер из шестнадцатого урока поднимается десять секунд, и за это время вкладка сделает шесть попыток вместо шестисот; при часовой аварии паузы упираются в потолок и вкладка пробует раз в полминуты вместо того, чтобы долбить. Websocket reconnect с backoff решает и встречную задачу: когда сервер оживает, тысячи вкладок не должны ломиться к нему синхронно — для этого пауза каждого клиента разбавляется джиттером, случайной добавкой до пары сотен миллисекунд, которая растаскивает попытки по времени. Счётчик попыток сбрасывается только при успешном рукопожатии, и это правило нарушают чаще всего.

### Клиент: connect, backoff, джиттер

```go
const url = "wss://chat.example/ws";
let socket = null;
let attempts = 0;

function connect() {
  socket = new WebSocket(url);

  socket.onopen = function () {
    attempts = 0; // рукопожатие удалось — счётчик в ноль
    socket.send(JSON.stringify({ type: "join", room: "general" }));
  };

  socket.onclose = function (event) {
    if (event.code === 1000) {
      return; // закрылись сами — не возвращаемся
    }
    const delay = Math.min(1000 * Math.pow(2, attempts), 30000);
    const jitter = Math.random() * 300;
    attempts = attempts + 1;
    setTimeout(connect, delay + jitter);
  };
}

connect();
```

Вся политика умещается в один onclose: код 1000 означает осознанное закрытие самим приложением, на него не реагируем; всё остальное — пауза min от базы в степени попыток до потолка, плюс джиттер, и setTimeout со следующей попыткой. Recursion через setTimeout не копит стек: каждая попытка живёт в новом тике таймера. Максимальное ожидание — тридцать секунд плюс случайные триста миллисекунд.

Три детали, на которых ломаются реализации. Первая: счётчик сбрасывается в onopen, а не в onclose — рукопожатие должно пройти целиком, иначе вкладка, которую сервер принял и сразу отклонил проверкой Origin, будет считать себя вечным новичком и штормить с базовой паузой. Вторая: перед отправкой любых кадров проверяйте socket.readyState — переподключение клиента асинхронно, и кадр из старого кода легко улетает в уже закрытый сокет пятого урока. Третья: не каждый close обязан рождать попытку. Код 1000 — сами закрыли по кнопке «выйти». Ответ 401 на рукопожатие означает просроченный токен: надо сходить за новым по обычному HTTP и только потом открывать сокет, а не долбить сервер с невалидным — это путь, который банят первым. Реакция на код — часть политики, а не универсальный retry. И держите переходное состояние видимым: пока вкладка в ожидании, интерфейс показывает «переподключаемся», а не молча притворяется, что всё хорошо, — пользователь должен знать, что последнее сообщение ещё в пути.

### Повторные подписки: новый сокет не помнит ничего

На сервере каждое соединение — новый Client в картах хаба: старый умер вместе с обрывом, и весь контекст сессии нулевой. Поэтому первое, что делает вкладка в onopen, — повторяет всё, что делала при первом входе: join в комнату из одиннадцатого урока, повторную авторизацию, если сервер её требует, пересылку presence-подписок. Подписки обязаны быть идемпотентными: повторный join в ту же комнату не должен давать дубликаты, и switchRoom из одиннадцатого урока именно так устроен — удалить, вычистить, добавить. Здесь же всплывает токен: соединение жило три часа, а JWT истёк через пятнадцать минут после рукопожатия, как в тринадцатом уроке; при переподключении вкладка обязана сначала получить свежий токен у обычного HTTP-эндпоинта и только потом открывать сокет. Жизненный цикл refresh-пар запротоколирован в курсе про JWT, здесь достаточно знать: reconnect — это новый гость со свежими документами, а не продолжение старого.

### Resume: догоняем пропущенное по seq

```go
case "resume":
        h.switchRoom(c, env.Room) // идемпотентно: повторный join без дубликатов
        for _, e := range h.history[env.Room] {
                if e.Seq <= env.LastSeq {
                        continue
                }
                select {
                case c.send <- e:
                default:
                        close(c.send)
                }
        }
```

Клиент шлёт конверт resume с именем комнаты и номером последнего виденного события; хаб повторяет идемпотентный вход и проигрывает историю поверх — всё, что с Seq больше LastSeq. История — тот самый срез последних кадров из домашнего задания одиннадцатого урока, теперь с монотонным номером. Буферный send и select с default остаются на месте: reconnecting-клиент не должен блокировать остальных.

Пропущенные события — отдельная тема, и у неё три честных решения по цене потери. Первое: ничего не делать, если события lossy по природе — presence, typing-индикаторы, счётчики онлайн; свежий снапшот при входе закрывает вопрос. Второе: seq и проигрыш истории из буфера сервера — подходит для чата, работает, пока буфер держит весь зазор, и требует честного лимита: если клиент отсутствовал сутки, история его не спасёт, и нужна третья схема. Третья: полный реснапшот через обычный REST при каждом входе — дороже, но безгранично: список непрочитанных и последние пятьдесят сообщений приходят HTTP-запросом, а websocket дальше шлёт только новое. Выбор зависит от того, сколько стоит пропажа: для уведомлений достаточно второго, для переписки с гарантией доставки нужен store-and-forward из двенадцатого урока плюс хранилище. На практике схемы комбинируют: дельты из буфера закрывают первые секунды, а REST-реснапшот при уходе из буфера страхует остальное.

### Встречный поток: что сервер делает с возвращающимися

Серверная сторона reconnect почти не требует кода, но требует гигиены. Возвращающийся клиент проходит обычное рукопожатие: токен проверяется, Client регистрируется, join обрабатывается — ничего специального. Тонкости две. Первая: старый Client ещё числится в картах до дедлайна из восемнадцатого урока, и при быстром переподключении у одного userID на секунды живут два соединения; карта устройств из двенадцатого урока переживает это спокойно, а одинарная карта просто затирается — осознанное поведение, а не баг. Вторая: лимит соединений на пользователя. Без него одна вкладка с ошибкой в цикле переподключения откроет сотни сокетов, и сервер первым узнает о баге клиента. Проверка в ветке register — не больше пяти устройств на userID, лишние отклоняются системным конвертом, — и reconnecting-шторм упирается в потолок, а не в память. Rate limit на сам register тут избыточен: backoff клиента уже растянул попытки, серверу достаточно не усиливать их.

> **Шторм воссоединения.** Сервер перезапустился, пять тысяч вкладок получили 1001 и все одновременно отсчитали одну секунду — волна попыток синхронна, как аплодисменты. Backoff без джиттера именно так и работает: паузы у всех одинаковые. Джиттер растаскивает попытки, а потолок не даёт вкладкам сойтись в общий такт после долгой аварии. Моё правило: ретраить с одним и тем же интервалом без случайной добавки — значит запланировать шторм заранее, вы просто выбираете его дату.

### Код закрытия и реакция вкладки

| Код | Что случилось | Реакция клиента |
| --- | --- | --- |
| 1000 | закрылись сами: кнопка, выход | не переподключаться |
| 1001 | сервер ушёл: рестарт, деплой | backoff и переподключение |
| 1006 | обрыв без рукопожатия: сеть | backoff и переподключение |
| 1011 | ошибка на сервере | backoff, при повторах — пауза растёт |
| HTTP 401 на рукопожатии | токен просрочен | обновить токен, затем открыть сокет |

### Проверка: рост паузы

**Вопрос:** База — одна секунда, потолок — тридцать. Какая пауза после четвёртой неудачной попытки подряд?

- Четыре секунды
- Шесть секунд
- ✅ Восемь секунд
- Шестнадцать секунд

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

### Проверка: сброс счётчика

**Вопрос:** Когда правильно обнулять счётчик попыток backoff?

- В onclose, когда соединение закрылось
- ✅ В onopen, после успешного рукопожатия
- Никогда: счётчик живёт вместе со страницей
- В момент отправки первого сообщения

Только полный успех рукопожатия доказывает, что связь восстановлена: сброс в onopen. Если сбрасывать в onclose, вкладка, которую сервер отклоняет на проверке Origin, вечно начинает с базовой паузы и штурмует сервер каждые полторы секунды вместо честного роста до потолка.

### Потолок backoff

```go
const base = 1000;
const cap = 4000;
const delays = [];
for (let attempts = 0; attempts < 5; attempts = attempts + 1) {
  delays.push(Math.min(base * Math.pow(2, attempts), cap));
}
```

**Что выведет программа?**

- 1000, 2000, 4000, 8000, 16000
- ✅ 1000, 2000, 4000, 4000, 4000
- 1000, 1000, 1000, 1000, 1000
- 4000, 4000, 4000, 4000, 4000

Двойка растёт: тысяча, две, четыре — а на четвёртой попытке минимум выбирает потолок: четыре против восьми. Дальше все паузы равны четырём секундам. Именно поэтому потолок ставят явно: без него «бесконечная авария» за пару часов разогнала бы паузы до часов, и вкладка фактически перестала бы возвращаться.

> **Проверьте возврат локально.** Соберите клиент с backoff и сервер из прошлого урока на локальном Go. Откройте вкладку, прервите сервер Ctrl+C — вкладка получит 1001 и начнёт паузы; поднимите сервер обратно и убедитесь, что onopen вернул счётчик в ноль и повторил join. Затем в devtools выберите Offline на несколько секунд: вкладка переживёт пропажу и вернётся после возврата сети. Два сценария закрывают девяносто процентов реальных обрывов.

### Соберите ветку onclose

Впишите три пропуска: поле события с кодом закрытия, основание степени и прирост счётчика попыток.

```go
socket.onclose = function (event) {
  if (event.___ === 1000) {
    return;
  }
  const delay = Math.min(1000 * Math.pow(___, attempts), 30000);
  attempts = attempts + ___;
  setTimeout(connect, delay);
};
```

Подсказка: Код закрытия лежит в event.code, экспонента — двойка, а счётчик попыток растёт на единицу перед планированием следующей попытки.

Правильные ответы в пропусках: `code`, `2`, `1`

### Соберите планировщик попыток

Расставьте строки функции переподключения: подсчёт паузы, учёт попытки и отложенный вызов connect.

Правильный порядок строк:

1. `function scheduleReconnect() {`
2. `const delay = Math.min(1000 * Math.pow(2, attempts), 30000);`
3. `attempts = attempts + 1;`
4. `setTimeout(connect, delay);`
5. `}`
6. `scheduleReconnect();`

### Карточки: reconnect

- **Экспоненциальный backoff** — Пауза между попытками удваивается от базы до потолка: 1, 2, 4, 8 секунд. Сервер получает паузу вместо шторма, авария гаснет сама.
- **Джиттер** — Случайная добавка к паузе, сотни миллисекунд. Растаскивает попытки тысяч вкладок по времени, чтобы они не сошлись в общий такт.
- **Сброс счётчика** — Только в onopen: успешное рукопожатие — единственное доказательство восстановления связи. Сброс в onclose рождает шторм с базовой паузой.
- **Идемпотентные подписки** — Повторный join не даёт дубликатов: switchRoom удаляет, чистит и добавляет заново. Новый сокет не помнит старых комнат.
- **resume и lastSeq** — Клиент называет номер последнего виденного события, сервер проигрывает историю с большим Seq. Потеря закрывается, пока зазор влез в буфер.

Клиентская политика собрана: паузы растут, шторм рассыпан джиттером, подписки идемпотентны, зазор закрывается resume. Обратная сторона истории — серверная: соединения, которые умерли молча и ещё не отпущены, разбирает [восемнадцатый урок про отвалы и таймауты](/courses/websocket/lessons/18-otvaly-i-tajmauty/). Прощальный кадр 1001, на который реагирует наш onclose, — наследие [шестнадцатого урока про graceful shutdown](/courses/websocket/lessons/16-graceful-ws/), а поведение самих событий onopen и onclose с кодами — [пятый урок про браузерный клиент](/courses/websocket/lessons/05-js-klient/). Свежий токен перед переподключением и жизненный цикл refresh-пар — в [курсе JWT в Go](/courses/jwt-go/), особенно [урок про жизненный цикл токенов](/courses/jwt-go/lessons/19-zhiznennyj-cikl/). Конверты с Seq и типами событий лежат в [десятой теме про JSON-протокол](/courses/websocket/lessons/10-json-soobscheniya/). Проверьте оба сценария на локальном Go: рестарт сервера и Offline в devtools, плюс третий — испорченный токен, который должен остановить цикл, а не раскрутить его. План курса — на странице [курса WebSocket](/courses/websocket/).

## Коротко: что запомнить

- Backoff удваивает паузу от базы до потолка: сервер после рестарта получает попытки через 1, 2, 4, 8 секунд, а не шквал.
- Джиттер — случайная добавка к паузе: без него тысячи вкладок сошлись бы в синхронный шторм воссоединения.
- Счётчик попыток сбрасывается только в onopen — рукопожатие целиком, а не onclose.
- Новый сокет ничего не помнит: join, авторизация и presence-подписки повторяются и обязаны быть идемпотентными.
- Resume с lastSeq и историей хаба закрывает зазор пропущенных событий, пока он влезает в буфер.
- Не всё ретраится: 1000 — сами закрыли, 401 — сначала свежий токен по HTTP, потом сокет.

## Домашнее задание

Дополните клиент из пятого урока политикой этого урока: экспоненциальный backoff с потолком в тридцать секунд, джиттер, сброс счётчика в onopen, отказ от переподключения на коде 1000. Затем добавьте в протокол Seq на сервере и команду resume: после reconnect вкладка должна догнать пропущенные кадры из истории комнаты. Проверьте локально три обрыва: Ctrl+C сервера, Offline в devtools и просроченный токен.

## Вопросы и ответы по уроку

### Какую базу и потолок выбрать?

Обычный диапазон для чатов: база одна-две секунды, потолок тридцать-шестьдесят. Меньшая база быстрее возвращает после короткого рестарта, но чаще дёргает сервер при аварии; больший потолок экономит трафик, но клиент дольше замечает восстановление. Мобильным клиентам берут потолок побольше — их обрывы дольше.

### Нужно ли ограничивать число попыток?

С потолком пауз это почти не нужно: вкладка пробует раз в полминуты, и это дёшево. Ограничение оправдано для фоновых вкладок и мобилок: после N часов ожиданий имеет смысл остановиться и показывать кнопку «переподключиться вручную» — держать ретраи ради мёртвой сессии расточительно.

### Почему нельзя переподключаться сразу без пауз?

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