Седьмая вкладка не грузится
Браузер открывает не больше шести 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 записывает стрим: Перехват и просмотр трафика.
Рядом
- SSE приходит одним куском: буферизация nginxСобытия Server-Sent Events прилетают пачкой вместо потока. Их держат три слоя: proxy_buffering, gzip и само приложение. Как проверить каждый.
- Стрим обрывается через 60 секунд: proxy_read_timeoutОбрыв на одной и той же секунде означает таймаут, а не сеть. Значения по умолчанию в nginx, ALB, Cloudflare, Heroku и Envoy и почему пинг надёжнее.