Классический HTTP строится на простой идее: клиент спросил, сервер ответил, соединение закрыто. Эта модель десятилетиями тащила на себе весь интернет, и она прекрасна, пока страница - это документ. Но как только интерфейсу нужно обновляться чаще, чем пользователь успевает нажимать F5, модель запрос-ответ начинает скрипеть. Веб-сокеты появились именно здесь: они превращают браузер из вежливого просителя в полноценного участника разговора, где обе стороны говорят первыми. Ниже разберём, как устроено upgrade-рукопожатие, чем фреймы отличаются от запросов, зачем нужны heartbeat-пакеты, и почему Server-Sent Events иногда оказываются честнее "настоящего" realtime.

Модель запрос-ответ и где она ломается

HTTP-запрос - это маленький ритуал с накладными расходами. Три-way handshake TCP, потом TLS-сессия, потом заголовки на пару килобайт, потом само тело. На одном запросе всё это ерунда, но когда интерфейс опрашивает сервер каждые две секунды, 95 процентов трафика уходит на обёртку, а не на данные. Long polling и короткий polling - это костыли, которые веб-инженеры годами подпихивали под безразличный к изменениям протокол: сервер либо держит запрос открытым до появления новостей, либо клиент долбится по таймеру. Оба варианта грузят соединение пустой работой и дают задержку от секунд в лучшем случае.

Больше всего страдают сценарии, где событие рождается на сервере. Курс валют, статус заказа, новое сообщение в рабочем чате, ход матча. Клиент физически не может "спросить вовремя", потому что не знает, когда наступит это "вовремя". Push от сервера в HTTP/1.1 просто не существует: сервер не имеет права написать первым.

Upgrade-рукопожатие и статус 101

WebSocket хитёр: он начинается как обычный HTTP-запрос, чтобы пройти сквозь всю существующую инфраструктуру - фаерволы, балансировщики, обратные посредник уровня приложения, всё то, что привыкло к понятным заголовкам. Клиент шлёт GET с заголовками Upgrade websocket, Connection Upgrade, Sec-WebSocket-Version 13 и случайным Sec-WebSocket-Key из шестнадцати байт в base64. Сервер, если согласен, берёт этот ключ, склеивает его с фиксированной строкой-идентификатором протокола, считает SHA-1 и отвечает статусом 101 Switching Protocols с заголовком Sec-WebSocket-Accept.

Этот маленький криптографический танец нужен не для секретности, а для защиты от случайностей: он доказывает, что обе стороны реально говорят на WebSocket, а не посредник где-то по дороге переупаковал кэшированный ответ и не выдал его за свежий. После 101 протокол HTTP на этом TCP-соединении заканчивается навсегда. Дальше - бинарный двунаправленный поток фреймов, и никаких заголовков больше не будет.

Важный нюанс с безопасностью на старте. CORS к upgrade-запросу не применяется: браузер не делает preflight, просто шлёт Origin как обычный заголовок. Значит, проверять Origin должен сам сервер, иначе любой сайт откроет сокет от имени залогиненного пользователя - куки при upgrade браузер прикладывает охотно. Это классическая дыра, о которую обжигались многие: аутентификация по куке без проверки Origin превращает чат в чужие руки.

Фреймы вместо запросов

После рукопожатия данные ходят фреймами. Каждый фрейм - это крошечный заголовок на два-четырнадцать байт плюс полезная нагрузка. В заголовке сидит фин-бит, опкод - текст, бинарные данные, пинг, понг, закрытие - и длина. Никаких имён заголовков, никаких cookie, никакого кэширования. По сравнению с килобайтным HTTP-заголовком на каждое сообщение экономия колоссальная, особенно на частых мелких событиях вроде координат курсора или тиков котировок.

Соединение полнодуплексное: обе стороны пишут одновременно и независимо. Сервер пушит событие в момент его рождения, клиент мгновенно отвечает. Задержка между "произошло" и "показано" сжимается до сетевого RTT плюс миллисекунды на обработку. Именно поэтому realtime без перезагрузки страницы стал нормой, а не фокусом: браузер получил фрейм, колбэк onmessage отработал, DOM перерисовался, пользователь даже не моргнул.

Чаты, тикеры и дешевая публикация событий

Классические сценарии, где WebSocket раскрывается полностью:

  1. Рабочие чаты поддержки и командные переписки, где сообщение должно появиться у всех участников мгновенно.
  2. Финансовые тикеры и биржевые стаканы с десятками обновлений в секунду.
  3. Индикаторы присутствия: кто онлайн, кто печатает, кто смотрит тот же документ.
  4. Совместное редактирование, где каждое нажатие клавиши - это событие.
  5. Игровые сессии и лайв-результаты, где секунда задержки ломает впечатление.

Заметная закономерность: везде события маленькие, частые и рождаются на сервере. Там, где данные большие и редкие, скажем загрузка отчёта раз в день, старый добрый HTTP ничем не хуже, а кэшируемостью даже лучше.

Server-Sent Events как половинчатая альтернатива

Есть сценарии, где клиенту нечего сказать серверу: только слушать. Лента уведомлений, прогресс длинной задачи, лог сборки. Для них двунаправленность - лишний вес, и тут Server-Sent Events выглядят очень достойно. Это обычный HTTP-ответ с Content-Type text event-stream, который сервер просто не закрывает и дозаписывает строчками data двоеточие и текст. Плюсы приземлённые и приятные: работает поверх HTTP/1.1 и HTTP/2, автоматически переподключается самим браузером, поддерживает Last-Event-ID для догоняния пропущенного, дружит с привычными механизмами кэша и балансировки. Минусы тоже честные: только текст, только в одну сторону, а в HTTP/1.1 - ограничение на шесть соединений к одному хосту, которое при нескольких вкладках кусается. Половинчатость SSE - не дефект, а дизайн: платить за полнодуплексность там, где достаточно радио, нет смысла.

Миллион открытых соединений против миллиона запросов

Это меняет экономику сервера. Модель "поток на запрос" умирает сразу: миллион потоков операционная система не поднимет. Нужен событийный цикл - epoll, kqueue, IOCP - где один процесс держит десятки тысяч сокетов на неблокирующем вводе-выводе. Хорошая новость в том, что молчащее соединение почти бесплатно: несколько килобайт памяти на структуру сокета и буферы, ноль процессора. Плохая новость: бесплатно оно только пока молчит по-настоящему. Миллион клиентов, каждый из которых шлёт хоть что-то раз в секунду, - это уже миллион операций в секунду, и тут начинается шардирование, очереди сообщений и кластер узлов с распределением подписок.

Сравнение с HTTP тонкое. Миллион HTTP-запросов в минуту - это тоже нагрузка, но keep-alive и HTTP/2 мультиплексируют их красиво, и соединения между запросами никто не держит. Разница философская: HTTP платит за передачу, WebSocket платит за ожидание. Если события редки, держать сокет открытым ради одного сообщения в час - расточительство; если часты, polling сожжёт на заголовках больше, чем сокет на поддержке.

Heartbeat и война с idle-таймаутами

У постоянного соединения есть враг, о котором забывают в учебниках: балансировщики и корпоративные сетевые коробки с агрессивными idle-таймаутами. Соединение молчало шестьдесят секунд - и какой-нибудь ALB или файрвол молча выбросил его из таблицы. Обе стороны при этом уверены, что сокет жив, и клиент узнает правду только когда попытается писать. Лекарство стандартное - heartbeat. В протокол встроены ping и понг фреймы с опкодами, и сервер каждые тридцать секунд шлёт пинг, а клиент обязан ответить понгом. Живой обмен сбрасывает таймеры простаивающих посредников и одновременно работает датчиком смерти: два пинга без понга - соединение закрываем и переподключаемся. Период heartbeat подбирают строго меньше самого жадного таймаута на пути, обычно с запасом вдвое.

wss, CORS и диагностика в Network

В продакшене почти всегда wss - тот же протокол поверх TLS. Причины практичные: через чужие сети шифрованный трафик проходит заметно лучше, чем открытый, посредники не трогают то, чего не понимают, и смешанный контент на HTTPS-странице запрещён браузерами самим фактом.

Проверка Origin при upgrade уже упоминалась, но повторить не вредно: браузер не защитит, CORS тут молчит, так что сравнивать Origin со списком доверенных доменов - обязанность серверного кода, и делать это надо до выдачи 101.

Диагностика живёт во вкладке Network инструментов разработчика. Фильтр по WS показывает сокетные соединения, внутри каждого - панель Messages, где видно фреймы обоих направлений с метками времени. Зелёные стрелочки - входящие, красные - исходящие, пинги и понги видны как служебные фреймы, если инструмент их показывает. Сразу видны классика жанра: рукопожатие вернуло 200 вместо 101, значит посредник сглотнул заголовки Upgrade; соединение открывается и падает через минуту - это idle-таймаут и пора внедрять heartbeat; соединений в списке пять вместо одного - клиент переподключается без экспоненциальной задержки и душит сам себя. Там же, в Network, проверяется и весь HTTP-контекст: какие куки ушли с upgrade, не протухла ли сессия.

Что выбрать на практике

Решение почти всегда сводится к двум вопросам. Первый: кому принадлежит инициатива и как часто рождаются события. Частые мелкие события от сервера и необходимость клиенту быстро отвечать - WebSocket. Только слушать текстовый поток - SSE. Редкие и тяжёлые данные - обычный HTTP, возможно с кэшем. Второй вопрос операционный: готова ли инфраструктура держать сотни тысяч открытых сокетов, корректно посредникровать upgrade и не резать молчащих. WebSocket - это не "новый HTTP", а соседний протокол со своим профилем расходов. Понимание того, за что он платит и что покупает взамен - миллисекунды вместо секунд и push вместо опроса - отличает осознанный выбор от моды.

Почему соединение живёт долго. Открытый WebSocket - это не бесплатный постоянный поток, а обоюдное обязательство: сервер и клиент держат буферы, планировщики проверяют heartbeat, промежуточные фильтры учатся стирать мёртвые ссылки через простуки таблиц. Реальная система с сотнями тысяч субъектов опирается на разделённые пулы соединений и дисциплину коротких пингов: сообщение каждые тридцать секунд стоит мало, зато сбрасывает таймеры обоих балансировщиков и сохраняет топологию разговора видимой. Из этого следуют две привычки профессионального пользования. Первая: любой realtime-канал проектируется с перезахватом обрыва и восстановлением истории окон, потому что сети рвутся по причинам, которые даже идеальная архитектура не выбирает. Вторая: пульс канала документируется отдельно от логики данных, чтобы диагностики не управлялись через парочку форматов канала.

Сравнительная грамотность выбора. Когда встаёт вопрос реализации, взвешивание простое: поток уведомлений с сервера в текущем состоянии равен привычному SSE и его простоте, чат с участием клиентов требует WebSocket, синхронные интерфейсы управления не нуждаются ни в том, ни в другом. Хороший рефлекс - спросить себя, сколько правил обновления одна сторона способна пережить без запроса другой: если подписка односторонняя, уникальный вход уж наверняка есть; если оба порядка говорят много, постоянное соединение компенсирует свою цену немедленно. Эта дисциплина защищает от перегибов, в которых веб превращается в неимоверное устройство для очень простых макетов.

Итоговая разница сводится к механике ожидания: HTTP предполагает, что клиент сам спрашивает, когда что-то меняется, и стоит за запросом полный круг пути. WebSocket держит канал открытым, и сервер пишет сам в момент изменения. Чем больше событий в секунду, тем заметнее выигрыш постоянного соединения: отпадает удвоенная стоимость установки и заголовков, отменяется синхронизация опросов, а нагрузка на серверы ограничивается только работой отправки. Порог выбора оценивается так: если за секунду происходит больше одного изменения, выбирайте постоянное соединение; если реже - опроса достаточно.