Седьмая вкладка не грузится

Браузер открывает не больше шести HTTP/1.1-соединений на один origin. Один EventSource занимает одно навсегда, и седьмая вкладка просто ждёт.

Что это значит

Приложение открыто в семи вкладках. Шесть получают данные, седьмая висит: белый экран, один запрос в ожидании, в логах сервера пусто, потому что запрос так и не ушёл из браузера. Браузер открывает не больше шести HTTP/1.1-соединений на один origin, а долгий поток вроде EventSource занимает одно из них на всё время жизни вкладки.

Как исправить

Сначала посмотрим на цифры, с которыми придётся жить:

  • По шесть на origin в Chrome, Edge, Firefox и Safari.
  • Именно на origin, а не на домен: схема, хост, порт, как это определяет RFC 6454, пункт 3.2. https://example.com и https://api.example.com получают по шесть.
  • Эти шесть общие для всех вкладок профиля браузера. Поэтому ломается не только седьмая вкладка: пока соединение не освободится, до этого origin не дотянется ни один запрос ни из одной вкладки.
  • Число не задано ни одним RFC. RFC 9112, пункт 9.4 говорит лишь, что клиенту «следует ограничивать» число соединений, а потолок в два соединения из RFC 2616, пункт 8.1.4 отменён. Шесть браузеры подобрали сами, на опыте.

По-настоящему лечит HTTP/2. Он открывает одно соединение на origin и передаёт запросы потоками внутри него. Лимит потоков задаёт сервер параметром SETTINGS_MAX_CONCURRENT_STREAMS (RFC 9113, пункт 6.5.2, который рекомендует не меньше 100): nginx по умолчанию разрешает 128, так что шести потоков он и не заметит. HTTP/3 работает так же. Код приложения менять не нужно.

Проверить, включён ли он:

curl -sI --http2 https://example.com/ | head -1
# HTTP/2 200

В nginx 1.25.1 и новее:

listen 443 ssl;
http2 on;

В более старом nginx: listen 443 ssl http2;

Отсюда и «баг только в проде» или «только локально». Лимит срабатывает на той стороне, которая всё ещё говорит по HTTP/1.1, а это обычно локальный dev-сервер.

HTTP/2 поднимает потолок, но не убирает его. Откройте больше потоков, чем разрешил сервер, и они снова встанут в очередь, только уже на 128, а не на 6.

HTTP/2 недоступен? Один поток на все вкладки. SharedWorker держит одно соединение и раздаёт события вкладкам:

// sse-worker.js
const source = new EventSource('/events');
const ports = [];

self.onconnect = (e) => {
  const port = e.ports[0];
  ports.push(port);
  port.start();
};

source.onmessage = (event) => {
  for (const port of ports) port.postMessage(event.data);
};
// в каждой вкладке
const worker = new SharedWorker('/sse-worker.js');
worker.port.start();
worker.port.onmessage = (e) => handle(e.data);

SharedWorker нет в Safari до 16-й версии: его убрали в Safari 7 и вернули в Safari 16.0. Если старый Safari в списке поддержки, остаётся BroadcastChannel и выбор «главной» вкладки через navigator.locks (Safari 15.4+).

WebSocket тоже обходит лимит. Соединения WebSocket живут в отдельном пуле, и потолок там измеряется сотнями: 255 в Chrome, 200 в Firefox по умолчанию. Одного сокета на вкладку такой потолок даже не заметит.

Дешевле всего закрывать поток в неактивной вкладке:

let source = new EventSource('/events');

document.addEventListener('visibilitychange', () => {
  if (document.hidden) source.close();
  else source = new EventSource('/events');
});

Грубо, и есть нюанс: новый EventSource не отправляет Last-Event-ID, поэтому события, пришедшие пока вкладка была скрыта, теряются. Если это важно, передавайте последний id сами, например /events?from=<id>.

Чем это НЕ лечится

  • Шардингом по поддоменам (a.example.com, b.example.com). На HTTP/1.1 это работает и даёт по шесть соединений на каждый origin, но за каждый шард вы платите DNS-запросом и TLS-рукопожатием, а cookies и кэш раскалываются. На HTTP/2 выгоды нет совсем: Firefox может свести шарды обратно в одно соединение, если у них общий IP-адрес и один сертификат, что и разрешает RFC 9113, пункт 9.1.1.
  • Ретраями. Запрос не отвергнут, он в очереди. Повтор встанет в ту же.
  • Таймаутами на сервере. Сервер не знает об этих запросах.

Как увидеть это у себя

Девтулзы вкладки показывают только её собственные запросы, поэтому в седьмой вкладке видно одну строку в состоянии «Stalled» и ни слова о причине. Прокси видит все вкладки сразу: шесть живых соединений к одному хосту и седьмое, которое так и не открылось. Как Solpuga записывает стрим: Перехват и просмотр трафика.

Посмотреть в Solpuga

Рядом