Прямой ответ
WebSocket оправдан, когда сервер инициирует отправку (уведомления, живые данные, чаты) и обновления частые; для «обновлять раз в минуту» достаточно SSE или поллинга — они проще через прокси и авторизацию. Технический минимум надёжного WebSocket-сервиса: асинхронный стек (websockets/FastAPI/uvicorn), пинги/понги для обнаружения мёртвых соединений (TCP-разрыв молчалив), reconnect с backoff на клиенте, идентификация соединения (auth при рукопожатии или первым сообщением) и широковещание через pub/sub (Redis), когда инстансов сервера больше одного — иначе подписчик на другом воркере ничего не получит.
Протокол: что реально отличается от HTTP
WebSocket начинается HTTP-рукопожатием (Upgrade: websocket) и превращает соединение в двусторонний канал фреймов: сообщения (текст/бинарные) идут в обе стороны без запрос-ответной дисциплины, накладные расходы на сообщение — байты (против заголовков HTTP). Следствия для кода: соединение — долгоживущее состояние (не «обработчик запроса», а цикл приёма); порядок сообщений в рамках соединения гарантирован; «мёртвое» соединение (клиент пропал без FIN) обнаруживается только пингами — отсюда обязательный heartbeat (websockets-библиотека делает его из коробки, интервал/таймаут настраиваются; стандарт — RFC 6455). Прокси и балансировщики должны уметь Upgrade (nginx: proxy_set_header Upgrade/Connection; таймауты idle — согласовать с интервалом пингов).
Сервер: FastAPI или чистый websockets
Для приложения, у которого уже есть HTTP-API — WebSocket-эндпоинт FastAPI (@app.websocket("/ws"): accept → цикл receive/send → close; async-механика — из статьи про asyncio) — общий стек, зависимости, авторизация. Для «только WebSocket-сервиса» (гейтвей уведомлений) — библиотека websockets (минимальна и надёжна). Каркас обработчика: try/finally вокруг цикла (close всегда), проверка auth до accept (или сразу после с немедленным close при провале), валидация входящих сообщений pydantic-моделями (WebSocket — такая же граница недоверенного ввода), таймауты на idle-соединения. Состояние соединений — в памяти процесса (ConnectionManager: словарь user → сокеты), и это главный ограничитель масштаба (см. pub/sub ниже).
Клиентская сторона: reconnect — не опция
Браузерный WebSocket умирает при каждом чихе сети, и обработчик, который «просто подключился», в реальности живёт секундами. Обязательный клиентский паттерн: reconnect с экспоненциальным backoff и джиттером (1с → 2с → 4с... до 30–60с), очередь исходящих на время разрыва (или явная политика «не отправлять»), и восстановление состояния после reconnect (сервер не помнит, что вы пропустили — клиент переспрашивает снапшот или использует sequence-номера событий). «Молчаливый» полумёртвый сокет лечится клиентским пингом/проверкой readyState; UX — индикатор соединения, а не молчаливое вранье «всё актуально».
Масштабирование: pub/sub против «всё в памяти»
Один процесс хранит только свои соединения; за балансировщиком подписчик и издатель события оказываются на разных воркерах. Рабочая архитектура: события публикуются в Redis pub/sub (или очередь — Redis в dev-стеке), каждый инстанс WebSocket-гейтвея подписан и рассылает своим соединениям; «кому именно» — фильтрация по каналам (user_id/room) на стороне инстанса. Альтернативы по росту нагрузки: SSE + отдельный push-сервис, managed-сервисы (Pusher/Ably — когда realtime не ядро продукта), специализированные брокеры. Отдельные дисциплины: лимиты на соединение (частота сообщений, размер — защита от DoS одним клиентом), graceful shutdown (close-фреймы с кодом при деплое — клиенты переподключатся на новый инстанс), метрики (число соединений, возраст, reconnect-рейт — мониторинг).
Когда WebSocket не нужен
Честные альтернативы: SSE (Server-Sent Events) — сервер→клиент поток поверх обычного HTTP (проще прокси/авторизация, автореконнект встроен в браузерный EventSource) — идеален для уведомлений/лент; long polling — совместимость с любым; обычный polling — редкие обновления, минимум инфраструктуры. Правило: двусторонний поток в реальном времени (чат, коллаборация, игры) — WebSocket; односторонние обновления — SSE; «раз в минуту» — polling. Сложность WebSocket-инфраструктуры (heartbeat, reconnect, pub/sub) — реальная цена, и платить её за односторонние уведомления расточительно.
Практический сценарий: уведомления о статусе заказа
Спроектируем типовую realtime-фичу целиком: клиент хочет видеть смену статуса заказа без обновления страницы. Серверная часть: WebSocket-эндпоинт /ws/orders/{order_id} в FastAPI; при подключении — проверка авторизации (токен первым сообщением или в query с коротким TTL) и права на заказ (чужой заказ — close с кодом 4403 и понятной причиной); далее клиент уходит в цикл receive (пинги держат соединение живым), а серверная сторона регистрирует пару «order_id → сокет» в ConnectionManager. Изменение статуса происходит совсем в другом месте — в обработчике REST или потребителе очереди — и публикует событие в Redis pub/sub канал order_events; каждый инстанс гейтвея подписан на канал и рассылает событие тем соединениям, у которых открыт нужный заказ. Клиент: EventSource-подобная логика на WebSocket с reconnect-циклом (backoff 1→2→4→…→30 с с джиттером), после reconnect — повторная подписка на открытые заказы и снапшот-запрос (GET текущих статусов — события за время разрыва могли пропасть, и это нормальная, запроектированная ситуация). Фолбэк — SSE или поллинг раз в минуту при недоступности WS (корпоративные прокси режут Upgrade чаще, чем кажется). Метрики: активные соединения, reconnect-рейт, лаг доставки события — по ним видно здоровье фичи. Итог — ни одного опроса «а не изменилось ли» с клиента, и масштабируемая схема, которая переживёт рост числа инстансов.
FAQ
Как авторизовать WebSocket? Токен в query-параметре рукопожатия (виден в логах — с коротким TTL) или первым сообщением после accept (канал уже приватный — предпочтительнее), либо cookie при том же origin. Сессия проверяется при рукопожатии; отзыв токена — через закрытие соединения по событию.
Сколько соединений держит один инстанс? Порядки десятков тысяч на процесс при скромном трафике сообщений (ограничения — память на соединение и файловые дескрипторы, ulimit). Узкое место раньше наступит в частоте broadcast — считайте сообщения/сек, а не сокеты.
Нужен ли отдельный сервис под WebSocket? При малых нагрузках — нет (эндпоинт в основном приложении). Выделяют гейтвей, когда деплои основного сервиса рвут соединения чаще приемлемого, или нагрузка/стейт-логика realtime растёт отдельно.
Что за код закрытия 1006? Аномальное закрытие без close-фрейма (сеть/прокси убили соединение) — штатная ситуация, обрабатывается как «переподключиться»; в логах сервера её отсутствие при массовых 1006 — сигнал про таймауты прокси.
Источники: FastAPI WebSockets, документация websockets, RFC 6455 — ссылки в шапке.