Потоки в браузере: SSE и WebSocket

Три предыдущих выпуска серии закрыли цикл «запрос → ответ → отрисуй»: fetch работает по модели «сходил, получил, закончил». Но есть класс задач, где сервер должен сам что-то прислать клиенту, без нового запроса с его стороны: уведомления о новом сообщении, live-счётчик, стрим токенов от модели, индикатор долгой операции. Здесь HTTP-«запрос-ответ» либо раздувают в polling (браузер каждые 2 секунды переспрашивает «ну что?»), либо меняют саму модель соединения.

Это статья про два официальных long-lived канала и один «почти-канал»:

  • SSE (Server-Sent Events) — сервер пишет текстовые события в один text/event-stream, браузер (EventSource) читает и переподключается сам;
  • WebSocket — двусторонний канал поверх HTTP-handshake, с бинарными фреймами и своей протокольной механикой;
  • плюс, как «третий путь», — стриминг ответа через response.body / ReadableStream (он же даёт LLM-токен-стрим) — кратко для сравнения, подробно в статье про Fetch.

К октябрю 2026 оба API есть во всех актуальных десктопных и мобильных браузерах; точные версии внедрения отдельных нюансов (например, Last-Event-ID поведение, binaryType) сверяйте на caniuse — номера релизов мы здесь сознательно не проставляем. Всё разобранное ниже — стандартный язык (EventSource, WebSocket), без библиотек; для «своего» live-виджета они хорошо садятся на Web Components из прошлого выпуска.


Часть I. Зачем нужен long-lived канал

1. Проблема polling-а

Грубый путь: setInterval(async () => { const r = await fetch('/api/feed'); … }, 2000). Работает, но:

  • задержка: среднее время обнаружения события ≈ половина интервала; на быстрых каналах неприемлемо;
  • трафик: каждый цикл — полный HTTP-запрос с заголовками, даже если ничего нового;
  • нагрузка на сервер: «кликов» в разы больше полезной работы;
  • ставит в тупик прокси/CDN кэшированием ответов.

Polling оправдан ровно в одном случае: канал не выдержит долгоживущего соединения (агрессивный reverse-proxy с таймаутом, клиент за жёстким NAT без websocket) — и тогда берут long-poll (запрос висит до первого события или до таймаута сервера, потом сразу переспрашивает). Long-poll даёт почти ту же задержку, что push, но на полунулевом HTTP-протоколе с полным переоткрытием.

2. Три формы «живых данных»

МодельНаправлениеТранспортДуплексГотовность
Polling / long-pollклиент тянетобычный HTTPнетвсегда (это HTTP)
SSE (EventSource)сервер → клиентHTTP + text/event-streamоднонаправленныйвезде
WebSocket (WebSocket)в обе стороныHTTP-handshake → ws:///wss://полный дуплексвезде
Стриминг ответа (response.body)сервер → клиент (в рамках одного GET)HTTP chunked / ReadableStreamоднонаправленный, без реконнекта из коробкивезде

Последняя строка важна: стриминг токенов LLM — это не SSE и не WS, а просто чтение response.body как ReadableStream (чунки приходят по мере генерации). У него нет авто-реконнекта и нет push «после закрытия соединения», но для «нажал Generate → смотрю токены» он идеален и не требует нового серверного протокола.


Часть II. SSE: EventSource — самый дешёвый push

2.1 Формат на проводе

SSE — это обычный HTTP-ответ с заголовком Content-Type: text/event-stream; charset=utf-8, где тело — поток текстовых «кадров» формата поле: значение, разделённых пустой строкой:

data: привет\n
\n
id: 42\n
event: chat\n
data: {"text":"hello","from":"bob"}\n
\n
retry: 3000\n
\n
  • одна пустая строка = «событие готово, можно диспатчить»;
  • data: — тело; можно несколько data: строк подряд — они склеятся через \n;
  • event: — тип события (по умолчанию message);
  • id: — идентификатор (браузер запомнит и пришлёт при реконнекте);
  • retry: — рекомендуемая пауза переподключения в мс;
  • строка, начинающаяся с : (двоеточие, без пробела) — комментарий: им делают heartbeat, чтобы прокси не убивал «тихое» соединение.

2.2 Клиент: EventSource

const es = new EventSource('/sse/stream');   // метод только GET, кастомных заголовков нет

es.onopen    = () => console.log('канал открыт');
es.onmessage = (e) => console.log('default event:', e.data, 'id:', e.lastEventId);
es.onerror   = (e) => console.log('обрыв, браузер сам реконнектится');

// именованное событие (`event: chat` на сервере):
es.addEventListener('chat', (e) => {
  const msg = JSON.parse(e.data);
  // безопасный рендер — textContent (см. статьи про Fetch и XSS)
  document.querySelector('#feed').insertAdjacentText('beforeend', msg.text + '\n');
});

readyState у EventSource: CONNECTING (0) → OPEN (1) → CLOSED (2). Отличия от WebSocket — см. таблицу в §5.1 ниже.

2.3 Серверный пример (PHP-FPM + nginx)

Самое важное в SSE — что не буферизовать. nginx по умолчанию включает proxy_buffering, и «стрим» станет одним ответом в конце.

PHP
<?php
// /sse/stream
header('Content-Type: text/event-stream; charset=utf-8');
header('Cache-Control: no-cache');
header('X-Accel-Buffering: no');          // nginx: выключить буферизацию (без конфа)
header('Connection: keep-alive');

@ob_end_flush();                            // сбросить буферы PHP

// 1) сразу: «догнать» что пропустили (последний id пришёл в заголовке Last-Event-ID)
$last = $_SERVER['HTTP_LAST_EVENT_ID'] ?? '0';

while (true) {
    $row = wait_for_new_row_since((int)$last, 15); // ваш источник, ждёт до 15 сек
    if ($row !== null) {
        echo "id: {$row['id']}\n";
        echo "event: {$row['event']}\n";           // 'message'/'chat'/...
        echo 'data: ' . json_encode($row['payload'], JSON_UNESCAPED_UNICODE) . "\n\n";
        $last = (int)$row['id'];
    } else {
        echo ": ping\n\n";                           // heartbeat-комментарий
    }
    flush();                                        // отдать кадр в сокет немедленно
}

Ключевые детали: flush() после каждого кадра, X-Accel-Buffering: no (или proxy_buffering off; в конфиге nginx), таймаут на ожидание строки меньше, чем таймаут коннекта. И PHP-FPM воркер держится этим соединением — на больших нагрузках SSE на FPM стоит менять на рабочий «long-living» процесс (Swoole/RoadRunner, Go, Node) или на отдельный пул FPM только под stream-эндпоинты, иначе вы «съедите» worker pool.

2.4 Реконнект и Last-Event-ID

Когда TCP-соединение рвётся (не из-за явной ошибки), браузер сам переоткрывает EventSource и в заголовке Last-Event-ID присылает значение последнего полученного id:. Это даёт «догони меня» без потери событий: сервер отдаёт всё с id больше переданного. Поэтому в SSE всегда ставьте монотонный id: (номер строки, timestamp-коррелят) — иначе реконнект теряется и вы получите дыру в потоке.

2.5 Чего в SSE нет

  • POST / PUT / DELETE: только GET. Тело запроса не передать (можно query-параметры или потом подписаться через cookie).
  • Кастомных заголовков — в EventSource нет опции headers, body, credentials по умолчанию. Для токена в заголовке Authorization SSE не годится — там нужен WebSocket (см. §6.4, где и описана проблема) или стриминг через fetch.
  • Бинарных данных: поток текстовый (UTF-8). Файлы — только через base64/URL.
  • Контроля реконнекта руками: он внутри браузера (хотя можно закрыть и открыть снова — §7.1).
  • Одного лимита HTTP/1.1 не обошёл: браузер разрешает ~6 параллельных соединений на origin по HTTP/1.1, и SSE съедает одно, поэтому много SSE-вкладок на один домен «подохнут». На HTTP/2 это снято (мультиплексирование) — один TCP-коннект на все стримы. На HTTP/1.1 держите один общий SSE-стрим на страницу и роутите события внутри по event:, а не открывайте N EventSource.

2.6 CORS и куки

new EventSource(url, { withCredentials: true }) разрешает слать куки; сервер обязан ответить Access-Control-Allow-Origin: <точный origin> + Access-Control-Allow-Credentials: true (нельзя * вместе с withCredentials).

2.7 SSE внутри Web Components

Компонент из прошлого выпуска логично открывает SSE в connectedCallback и закрывает в disconnectedCallback (строго один EventSource на инстанс, иначе утечка):

class LiveTicker extends HTMLElement {
  connectedCallback() {
    this._es = new EventSource('/sse/ticker');
    this._es.addEventListener('tick', (e) => {
      const v = JSON.parse(e.data).value;
      (this.shadowRoot ??= this.attachShadow({ mode: 'open' })).textContent = String(v);
    });
  }
  disconnectedCallback() {
    this._es?.close();          // иначе браузер будет держать стрим даже после удаления
    this._es = null;
  }
}
customElements.define('live-ticker', LiveTicker);

Часть III. WebSocket: полный дуплекс

3.1 От handshake до канала

WebSocket живёт поверх HTTP: клиент шлёт GET с «апгрейд»-заголовками, сервер отвечает 101 Switching Protocols, и после этого TCP-соединение перестаёт быть HTTP и становится отдельным двунаправленным каналом с фреймами:

GET /ws/chat HTTP/1.1
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: <base64 nonce>
Sec-WebSocket-Version: 13
Origin: https://codestack.space

HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: <hash от key>

Sec-WebSocket-Accept — это SHA-1 от key + GUID — защита от «случайного» апгрейда обычного кэша/прокси; клиент его проверяет.

3.2 Клиент: WebSocket

const ws = new WebSocket('wss://codestack.space/ws/chat');
// const ws = new WebSocket(url, ['chat-v2']);   // субпротокол для handshake (см. §3.7)

ws.binaryType = 'arraybuffer';        // бинарные фреймы → ArrayBuffer (по умолчанию 'blob')

ws.onopen = () => ws.send(JSON.stringify({ op: 'join', room: 'main' }));
ws.onmessage = (e) => {
  if (typeof e.data === 'string') {
    handle(JSON.parse(e.data));
  } else {
    handleBinary(e.data);              // ArrayBuffer, если binaryType='arraybuffer'
  }
};
ws.onclose = (ev) => {
  // ev.code (см. §3.6), ev.reason, ev.wasClean
  scheduleReconnect(ev);
};
ws.onerror = () => { /* событие error без деталей; реконнект — в onclose */ };

ws.readyState: CONNECTING (0), OPEN (1), CLOSING (2), CLOSED (3). send() в CONNECTING бросит InvalidStateError; перед отправкой проверяйте готовность:

function safeSend(ws, payload) {
  if (ws.readyState === WebSocket.OPEN) ws.send(payload);
  else ws.addEventListener('open', () => ws.send(payload), { once: true });
}

3.3 Закрытие и коды статуса

ws.close(1000, 'bye');                 // code и причина (UTF-8, ≤123 байт)

Кодекс: 1000 нормальное, 1001 going away, 1012 restart, 1013 retry later, 1015 TLS (зарезервирован, onclose его не отдаёт). Клиент сам может ставить только 1000 или 3000–4999 (остальные зарезервированы/для сервера). При некорректном коде — будет error. wasClean в onclose говорит, был ли корректный close-фрейм, или соединение «лопнуло».

3.4 Backpressure: bufferedAmount

У клиента есть очередь отправки. Если вы лопатите в send() быстрее, чем уходит в сокет, ws.bufferedAmount растёт, и это память клиента. Паттерн «drain» — не слать новый кадр, пока буфер не опустел:

function sendWithBackpressure(ws, chunks, done) {
  const i = 0;
  const flush = () => {
    if (i >= chunks.length) { done?.(); return; }
    if (ws.bufferedAmount < 64 * 1024) {          // порог по вкусу
      ws.send(chunks[i++]);
      flush();
    } else {
      setTimeout(flush, 16);                        // вернёмся на следующем тике
    }
  };
  flush();
}

Серверная backpressure-механика в WebSocket-протоколе тонкая (есть flow-control на уровне TCP); если сервер шлёт быстрее, чем клиент читает, у клиента растёт receive-буфер. Держите сообщения разумного размера.

3.5 Heartbeat

WS-протокол знает ping/pong-фреймы (для keepalive и обнаружения мёртвого соединения), но JS API их не показывает — браузер шлёт/принимает их сам. Поэтому на прикладном уровне heartbeat обычно делают «своим» сообщением: раз в N секунд send({op:'ping'}), сервер отвечает pong; если pong не пришёл за 2 timeout — ws.close() и реконнект. Это защищает от «zombie-коннекта»: TCP жив, а логический канал задохнулся на полпути.

3.6 Субпротоколы (и передача авторизации, см. §6.4)

const ws = new WebSocket('wss://…', ['access_token.' + token, 'chat-v2']);

Список субпротоколов уходит в заголовке Sec-WebSocket-Protocol handshake — это один из немногих мест, куда реально донести токен при WS-подключении (см. §6.4: заголовки/методы в WS недоступны). Сервер выбирает один и возвращает его в Sec-WebSocket-Protocol; если не вернёт — браузер закроет соединение.

3.7 WebSocket vs EventSource: readyState-таблица (сводка)

СвойствоEventSource (SSE)WebSocket
Направлениесервер → клиентоба
Данныетекст UTF-8текст + бинарные (ArrayBuffer/Blob)
Метод/заголовкитолько GET, без кастомныхHTTP-handshake, тело нет
Реконнектавто + Last-Event-IDруками (см. §5)
Типы событийтип через event:только сообщение, типы — в payload
Прокси-требованияобычное HTTP, нужен X-Accel-Bufferingнужен Upgrade-aware proxy (proxy_pass + Upgrade headers в nginx)

Часть IV. Когда SSE, когда WebSocket, когда стриминг

4.1 Выбор за 30 секунд

Серверу надо писать клиенту БЕЗ запроса с его стороны?
├─ нет → fetch / ReadableStream (стриминг ответа: токен-генерация, большой CSV)
└─ да →
   ├─ поток только «сервер → клиент» и это текст/JSON-события?
   │   └─ SSE. Меньше кода, авто-реконнект, догоняет по id.
   └─ нужен «клиент → сервер» тоже (чат, совместное редактирование, игры)?
       └─ WebSocket. Готовьтесь руками: реконнект, heartbeat, backpressure.

4.2 Практические подсказки

  • LLM-токены / «печатающийся ответ» — это не SSE (вы не ждёте push от сервера, вы сами инициировали и читаете ответ). Правильно: fetch(..., {method:'POST'}) + for await (const chunk of res.body) / TextDecoderStream (см. статью про Fetch). SSE был бы оправдан, если бы ответ генерился асинхронно в фоне (задача в очереди, браузер слушает прогресс).
  • Уведомления / счётчики / статусы — SSE.
  • Чат / live-редактирование / мультиплеер / торговый терминал — WebSocket.
  • Загрузка больших файлов с прогрессом — ни то, ни это, а ReadableStream (upload) + стриминг ответа.
  • Долгая фоновая задача (экспорт, рендер) — SSE-прогресс + обычный POST для старта, это «push + poll» гибрид без дуплекса.

4.3 Long-poll как fallback

Если инфраструктура убивает долгие соединения (агрессивный балансировщик) или нужен строгий HTTP-only транспорт: long-poll (fetch висит до события или до 20 сек, потом немедленный новый запрос). По задержке почти как push, но дороже по TCP/TLS и без догона (Last-Event-ID).


Часть V. Надёжность: реконнект, visibility, backoff

5.1 Почему «авто-реконнект» в SSE — не всё

Браузер переподключает SSE, но если сервер вернул 404/401 или неверный content-type, EventSource уйдёт в CLOSED без ретраев. Значит логику «догнать и не пропустить» всё равно держите руками: слушайте onerror, следите за readyState === CLOSED и при необходимости new EventSource(...).

5.2 Экспоненциальный backoff + jitter (для WS)

Реконнект по одному и тому же «сейчас же снова!» убивает сервер после массового обрыва (thundering herd). Классика — экспоненциальный рост с ограничением и джиттером:

function connect(url) {
  const ws = new WebSocket(url);
  let attempt = 0, timer;

  const delay = () => {
    attempt = Math.min(attempt + 1, 6);            // потолок 2^6 = 64 сек
    const base = Math.min(1000 * 2 ** attempt, 64000);
    const jitter = Math.random() * 1000;           // размазываем одновременные реконнекты
    return base + jitter;
  };

  const reconnect = () => {
    clearTimeout(timer);
    timer = setTimeout(() => connect(url), delay()); // рекурсия на свежем инстансе
  };

  ws.onopen  = () => { attempt = 0; };               // удалось — сбросить счётчик
  ws.onclose = reconnect;
  ws.onerror = () => { try { ws.close(); } catch {} }; // закроется → onclose → reconnect
  return ws;
}

Важно: на onerror не открывайте новое соединение сразу — дождитесь onclose, иначе при сетевой ошибке получится двойной реконнект.

5.3 Вкладка «спрятана» — не держите канал

Браузер троттлит/замораживает фоновые таймеры и рвёт соединения; пользователь видит «нет новых сообщений». Правильная политика: на document.addEventListener('visibilitychange') при hidden — отложить реконнект; при visible — «свежий старт» (WS переподключить, SSE догнать по Last-Event-ID или по since=<ts> в query). Для SSE на hidden иногда закрывают канал сами: es.close(), при visible — создать новый.

5.4 Одинаковый ключ реконнекта и идемпотентность

Множественные реконнекты + «догон» по id/Last-Event-ID требуют идемпотентного применения сообщений на клиенте: храните lastSeenId и отбрасывайте id ≤ lastSeenId. Иначе после разрыва на одинаковых событиях в чате задублируется текст.


Часть VI. Безопасность потоков

6.1 WebSocket — не под CORS (главная грабля)

fetch подчиняется same-origin/CORS, и это «защита по умолчанию»: чужой сайт не прочитает ваш API без явного Access-Control-Allow-Origin. WebSocket так не защищён: браузер не блокирует открытие wss:// на чужой origin. От вредоносного веб-сайта вас отделяет только то, что сервер проверил заголовок Origin (или Cookie-сессию + CSRF-токен). Поэтому на сервере обязательно:

# nginx: пушать origin на бэкенд
proxy_set_header Origin $http_origin;
# на сервере WS перед апгрейдом:
if (!isAllowedOrigin(req.headers.Origin)) return 403;   // allow-list своих origins

Без проверки Origin любой сайт с JS сможет подключиться к вашему wss:// и читать ваш поток (или писать в чат от имени авторизованного пользователя через его cookie-сессию — классический WS-CSRF). SSE уязвим меньше: EventSource без withCredentials не шлёт куки, но с withCredentials + Access-Control-Allow-Origin: * (без куки) любой origin сможет читать ваш стрим — для чувствительных данных никогда не делайте CORS-открытым SSE.

6.2 XSS: не рендерите поток в innerHTML

Сообщения из WS/SSE — это данные извне. Вставляйте через textContent/insertAdjacentText, а при разметке — белый список тегов + DOMPurify (см. статьи про XSS и Fetch). Ни eval в JSON.parse, ни new Function от тела кадра.

6.3 Всегда wss:// / https://

  • ws:// не шифрует и в HTTPS-странице даст mixed-content (браузер заблокирует).
  • SSE на http:// из-под HTTPS-страницы то же самое. В prod — только wss:///https://.
  • Проверка на сервере TLS-сертификата (и на клиенте — wss от https origin) обязательна; не игнорируйте Sec-WebSocket-Protocol mismatch.

6.4 Авторизация: куда положить токен

В WS-рукопожатии клиент не может задать заголовок Authorization (и в SSE тоже). Варианты, от лучшего к худшему:

  1. Первое сообщение после onopen с токеном ({op:'auth', token}) — токен живёт в памяти JS, не в URL.
  2. Субпротокол ['access_token.'+token] (§3.6).
  3. HttpOnly-cookie + проверка Origin (если серверная сессия в куке) — но кука в WS шлётся автоматически, поэтому без Origin-валидации это дыра (§6.1).
  4. Токен в query (wss://…?token=…) — плохо: попадает в логи, в Referer, в кэш. Допустимо только short-lived одноразовый ticket.

SSE с withCredentials = вариант «кука». Если нужен Authorization: Bearer в SSE — SSE не подойдёт, идите в WS или в стриминг fetch (там заголовки есть).

6.5 Rate-limit и размер сообщений

Сервер WS/SSE обязан лимитировать частоту и размер: «100 сообщений в секунду», «сообщение ≤ 64 КБ». Иначе один клиент флудом забивает память/канал, а bufferedAmount (§3.4) превращается в DoS на клиенте.


Часть VII. Практика: live-ticker на SSE с реконнектом и health

7.1 Полный компонент

class LiveTicker extends HTMLElement {
  static observedAttributes = ['src'];
  #es = null;
  #retry = 0;

  attributeChangedCallback(_, o, v) { if (o !== undefined) this.#connect(v); }
  connectedCallback()    { this.#connect(this.getAttribute('src')); }
  disconnectedCallback() { this.#close(); }

  #close() {
    this.#es?.close();
    this.#es = null;
    this.#retry = 0;
  }

  #connect(url) {
    this.#close();
    if (!url || !this.isConnected) return;        // reconnect только если элемент в DOM

    this.#es = new EventSource(url, { withCredentials: true });

    this.#es.addEventListener('open', () => {
      this.#retry = 0;                            // канал живой — счётчик в ноль
      this.#setState('live');
    });

    this.#es.addEventListener('tick', (e) => {
      const v = JSON.parse(e.data).value;
      this.#root().textContent = v;               // safe render
    });

    this.#es.addEventListener('error', () => {
      if (this.#es.readyState === EventSource.CLOSED) {
        // браузер сам не переоткроет (401/404/415/не text/event-stream) —
        // переподключаемся руками с backoff
        this.#backoff(url);
      } else {
        this.#setState('retry');                  // браузер сам реконнектится
      }
    });
  }

  #backoff(url) {
    this.#retry = Math.min(this.#retry + 1, 6);
    const delay = Math.min(1000 * 2 ** this.#retry, 60000) + Math.random() * 500;
    setTimeout(() => this.#connect(url), delay);
  }

  #root() { return this.shadowRoot ?? this.attachShadow({ mode: 'open' }); }
  #setState(s) { this.setAttribute('state', s); } // снаружи: :host([state="retry"]) …
}
customElements.define('live-ticker', LiveTicker);
<live-ticker src="/sse/ticker"></live-ticker>

Что учтено по статье: один EventSource на инстанс и снятие при disconnect (§2.7), догон по авто-Last-Event-ID (браузер, §2.4), обработка «CLOSED» с ручным backoff+jitter (§5.2), проверка isConnected перед reconnect (§5.1), withCredentials+ Origin-валидация на сервере (§6.1/§6.4), safe-render через textContent (§6.2).

7.2 Чек-лист по интеграции на бэкенде

  • nginx: SSE — proxy_buffering off; / X-Accel-Buffering: no; WS — proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade";; поднять proxy_read_timeout выше heartbeat.
  • PHP-FPM: не держать SSE-стрим в основном пуле (worker starvation) — отдельный long-lived процесс/пул или переход на Swoole/RoadRunner.
  • heartbeat: период < proxy_read_timeout, чтобы не рвалось.
  • Last-Event-ID / since: сервер умеет «догнать» после реконнекта.
  • Origin-allow-list для WS, куки + CSRF для SSE с withCredentials.
  • wss/https, лимиты частоты и размера сообщений.

Чек-лист: готовность и грабли

  • Не polling, если есть канал. setInterval(fetch) на «ну что?» — задержка, трафик, нагрузка. Long-poll только как fallback.
  • SSE = только GET, только текст, только сервер→клиент. Нет POST, нет кастомных заголовков (Authorization!), нет бинарных. Для bearer-токена — WS или стриминг fetch.
  • id: в SSE обязателен (монотонный) — без него авто-реконнект теряется и дыра в потоке; сервер читает Last-Event-ID.
  • Heartbeat через : ping-комментарий — иначе прокси/CDN убьют «тихий» SSE по таймауту.
  • nginx proxy_buffering off / X-Accel-Buffering: no для SSE; Upgrade/Connection для WS.
  • HTTP/1.1: один общий SSE-стрим, а не N EventSource (съедает лимит ~6 conns на origin). HTTP/2 снимает это.
  • PHP-FPM worker не должен держать долгие SSE на всем пуле — отдельный long-living процесс или отдельный пул.
  • WS: проверять readyState === OPEN перед send(). В CONNECTING он бросит InvalidStateError.
  • WS: авто-реконнекта нет — пишите руками, на onclose, с экспоненциальным backoff + jitter и сбросом счётчика на open. Не открывайте второй коннект на onerror до onclose.
  • WS: heartbeat прикладным ping/pong — JS API не показывает протокольные ping/pong; обнаружение zombie-канала на вас.
  • WS: bufferedAmount = backpressure. Не шлите в бесконечную очередь, ждите дренаж.
  • WS не под CORS. Сервер обязан валидировать Origin, иначе WS-CSRF/чтение чужого потока. SSE — куки + Access-Control-Allow-Origin с осторожностью.
  • Токен авторизации: первый auth-месседж / субпротокол / cookie+Origin. Query-параметр — только short-lived одноразовый ticket (уходит в логи/Referer).
  • wss:///https:// всегда, иначе mixed-content блокировка в HTTPS-странице.
  • Safe-render потока: textContent/insertAdjacentText; никакого innerHTML/eval от тела кадра.
  • visibility: hidden → не держать канал, visible → реконнект/догон.
  • Идемпотентность при догоне: отбрасывайте id ≤ lastSeenId, иначе дубли сообщений после разрыва.
  • WC-компонент: EventSource/WebSocket в connectedCallback, close() в disconnectedCallback — строго один на инстанс.

Материал носит образовательно-практический характер. Синтаксис и семантика соответствуют WHATWG/WebSocket API и Server-Sent Events (HTML Living Standard, EventSource/WebSocket). Поведение в старых/мобильных браузерах и в специфичных прокси (буферизация, Last-Event-ID на реконнект) сверяйте по MDN и caniuse для вашей аудитории.

👤 Зарегистрируйтесь, чтобы добавить материал в закладки.