The seventh tab never loads

A browser opens at most six HTTP/1.1 connections per origin. One EventSource holds one of them forever, so the seventh tab just waits.

What it means

Your app is open in seven tabs. Six get data, the seventh hangs: blank screen, one pending request, nothing in the server log, because the request never left the browser. A browser opens at most six HTTP/1.1 connections per origin, and a long-lived stream like EventSource holds one of them for the whole life of the tab.

How to fix it

First, the numbers you are up against:

  • Six per origin in Chrome, Edge, Firefox and Safari.
  • Per origin, not per domain: scheme, host and port, as RFC 6454 § 3.2 defines it. https://example.com and https://api.example.com get six each.
  • The six are shared by every tab in the browser profile. So a full pool does not only break the seventh tab. Nothing in any tab can reach that origin until a connection frees up.
  • No RFC sets the number. RFC 9112 § 9.4 only says a client "ought to limit" its connections; the two-connection ceiling of RFC 2616 § 8.1.4 is gone. Six is a browser choice, found by experiment.

The real fix is HTTP/2. It opens one connection per origin and carries requests as streams inside it. The server sets the stream cap through SETTINGS_MAX_CONCURRENT_STREAMS (RFC 9113 § 6.5.2, which recommends no less than 100), and nginx allows 128 by default, so six streams is nothing. HTTP/3 works the same way. No application code changes.

Check whether it is on:

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

In nginx 1.25.1 and newer:

listen 443 ssl;
http2 on;

Older nginx: listen 443 ssl http2;

This is why the bug looks like "only in production" or "only locally". The limit bites on whichever side still speaks HTTP/1.1, and that is usually the local dev server.

HTTP/2 raises the ceiling, it does not remove it. Open more streams than the server's cap and they queue again, at 128 instead of 6.

No HTTP/2? One stream for all tabs. A SharedWorker holds a single connection and fans events out:

// 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);
};
// in every tab
const worker = new SharedWorker('/sse-worker.js');
worker.port.start();
worker.port.onmessage = (e) => handle(e.data);

SharedWorker is missing in Safari before 16: it was dropped in Safari 7 and came back in Safari 16.0. If older Safari is on your list, the fallback is BroadcastChannel plus a leader tab elected with navigator.locks (Safari 15.4+).

A WebSocket also sidesteps it. WebSockets live in a separate pool, and the cap there is in the hundreds: 255 in Chrome, 200 in Firefox by default. One socket per tab is then fine.

The cheapest fix is to close the stream in a hidden tab:

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

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

Crude, and note the gap: a fresh EventSource sends no Last-Event-ID, so events that fired while the tab was hidden are lost. Pass the last id yourself (/events?from=<id>) if that matters.

What does not fix it

  • Sharding across subdomains (a.example.com, b.example.com). On HTTP/1.1 it works and gives you six per origin, times the number of origins, but you pay a DNS lookup and a TLS handshake per shard, and you split cookies and cache. On HTTP/2 it buys nothing, and Firefox may coalesce the shards back onto one connection when they share an IP address and a certificate, which RFC 9113 § 9.1.1 permits.
  • Retries. The request was not refused, it is queued. A retry joins the same queue.
  • Server-side timeouts. The server does not know these requests exist.

See it on your own traffic

A tab's devtools shows only that tab's requests, so the seventh tab gives you one row stuck in "Stalled" and no reason. A proxy sees every tab at once: six live connections to one host, and a seventh that never opened. See Capture & Inspect Traffic for how Solpuga records a stream.

View in Solpuga

Related