Diagnosing the Six-Connection Limit per Origin Permalink to this section
Part of HTTP/2 and HTTP/3 for Event Streams, under SSE Protocol Fundamentals & Architecture.
This bug produces no error, no log line and no failed request. The application simply stops responding to some users, usually the most engaged ones, and only in certain tab configurations. It is the HTTP/1.1 per-origin connection limit, and it is the most common way a working SSE feature breaks the rest of a site.
Symptom & Developer Intent Permalink to this section
The observable pattern:
- The page loads normally, then later requests β images, autocomplete, form submissions β hang indefinitely with no error.
- DevTools shows requests stuck in Queueing or Stalled with a duration that grows until the user closes a tab.
- The problem appears only after the user opens a third or fourth tab, or after visiting a page that opens several streams.
- Closing one tab instantly unblocks everything, which makes the bug look intermittent and unreproducible.
- Server logs show nothing at all: the requests never arrive.
The intent is to prove that connection exhaustion is the cause rather than a server-side slowdown, and then to reduce the number of connections the application holds.
Root Cause Analysis Permalink to this section
Browsers limit concurrent TCP connections to a single origin β six in every current desktop browser, and the same or fewer on mobile. The limit exists to protect servers and the network from a single page monopolising capacity, and for ordinary requests it is invisible because each connection is released the moment a response completes.
An SSE response never completes. Each open EventSource holds one of the six for as long as the stream lives, and the browser will not release it early. Two streams per tab across three tabs is six connections; the seventh request of any kind to that origin waits in a queue with no timeout and no error until a slot frees.
Three details make it harder to diagnose than it sounds.
The limit is per origin, not per tab. Tabs on the same site share the pool, so the number of streams that matters is the total across every open tab β which the page itself cannot observe.
Queued requests look like slow requests. DevTools reports the wait as Queueing time inside the request timeline. Without expanding the timing breakdown it reads as a slow server, and profiling the server finds nothing because the request never left the browser.
Different browsers count differently. Requests from a service worker, prefetches and preloads all draw from the same pool, so the exact threshold varies between browsers even though the limit is nominally the same.
Over HTTP/2 the constraint effectively disappears: streams are multiplexed over one connection, and the limit becomes the serverβs advertised concurrent-stream setting, typically 100 to 128.
Step-by-Step Resolution Permalink to this section
Step 1 β Confirm the diagnosis in DevTools Permalink to this section
Open the Network panel, reproduce the hang, and expand the timing breakdown of a stalled request. Connection exhaustion has a distinctive signature: nearly all the time is in Queueing, with Stalled and everything downstream at zero.
Request timing for a queued request:
Queueing 18.42 s β waiting for a connection slot
Stalled 0.11 ms
Request sent 0.04 ms
Waiting (TTFB) 0 ms β never reached the server
Then confirm the protocol: add the Protocol column and check the stream rows. If they read http/1.1, the limit applies. If they read h2, this is not your bug.
Step 2 β Count the streams the application actually opens Permalink to this section
The count is often higher than the design intends, because hooks and components open streams independently.
// Paste in the console: count live EventSource instances created from now on.
(() => {
const Native = window.EventSource;
const live = new Set();
window.EventSource = function (...args) {
const es = new Native(...args);
live.add(es);
const close = es.close.bind(es);
es.close = () => { live.delete(es); close(); };
console.log('[sse] open:', args[0], 'β live:', live.size);
return es;
};
window.EventSource.prototype = Native.prototype;
Object.assign(window.EventSource, Native);
window.__liveStreams = live;
})();
Reload the page and read __liveStreams.size. A number above one per tab is the immediate target, and a number that climbs during normal use is a leak β see Preventing EventSource Memory Leaks in React.
Step 3 β Collapse several streams into one multiplexed endpoint Permalink to this section
The cheapest structural fix is to stop opening a stream per feature. Serve one stream and distinguish payloads with the event: field.
// Before: three streams, three connection slots.
new EventSource('/api/notifications');
new EventSource('/api/prices');
new EventSource('/api/presence');
// After: one stream, one slot, three named event types.
const es = new EventSource('/api/stream?topics=notifications,prices,presence');
es.addEventListener('notification', (e) => onNotification(JSON.parse(e.data)));
es.addEventListener('price', (e) => onPrice(JSON.parse(e.data)));
es.addEventListener('presence', (e) => onPresence(JSON.parse(e.data)));
The server side is a filter on subscription rather than a new mechanism; the pattern is covered in Using Named Event Types in SSE.
Step 4 β Serve the stream over HTTP/2 Permalink to this section
This removes the limit rather than working around it, and it requires no client change. See Serving SSE over HTTP/2 with nginx for the configuration; the two prerequisites are TLS and removing hop-by-hop headers from the response.
Step 5 β Share one stream across tabs where a single stream per tab is still too many Permalink to this section
For applications where users routinely keep many tabs open, one connection per tab may still be too many β particularly on mobile. A SharedWorker holds one stream for the whole origin and broadcasts to every tab.
// sse-worker.js β one EventSource for every tab on this origin.
const ports = [];
let es = null;
self.onconnect = (e) => {
const port = e.ports[0];
ports.push(port);
port.start();
if (!es) {
es = new EventSource('/api/stream');
es.onmessage = (ev) => ports.forEach((p) => p.postMessage({ type: 'message', data: ev.data }));
es.onerror = () => ports.forEach((p) => p.postMessage({ type: 'error' }));
}
port.onmessage = (msg) => {
if (msg.data === 'close') {
const i = ports.indexOf(port);
if (i !== -1) ports.splice(i, 1);
if (!ports.length) { es.close(); es = null; } // last tab out closes the stream
}
};
};
// page.js β every tab talks to the worker instead of the network.
const worker = new SharedWorker('/sse-worker.js');
worker.port.start();
worker.port.onmessage = (e) => {
if (e.data.type === 'message') applyUpdate(JSON.parse(e.data.data));
};
window.addEventListener('pagehide', () => worker.port.postMessage('close'));
SharedWorker is unavailable in some mobile browsers, so treat it as an enhancement with the per-tab stream as the fallback. A BroadcastChannel with leader election achieves the same result with wider support and more code.
Validation & Monitoring Permalink to this section
The decisive test is behavioural, not instrumental: open seven tabs of the application and confirm that page loads, images and API calls still complete normally in the seventh.
# Server-side: how many concurrent streams does one user account hold?
# A count consistently above the tab count means streams are leaking.
curl -s localhost:9090/metrics | grep '^sse_streams_open'
// Client-side: report the connection count so the field data is real.
setInterval(() => {
if (window.__liveStreams) beacon('sse_live_streams', window.__liveStreams.size);
}, 60_000);
In DevTools, the confirmation that the fix landed is that Queueing time returns to near zero for ordinary requests while streams remain open, and that the Protocol column reads h2 for the stream rows. Both are worth capturing in a screenshot on the day of the change β this is a bug that tends to return quietly when someone adds a new feature stream.
Production Checklist Permalink to this section
Frequently Asked Questions Permalink to this section
Is the limit really six, and is it the same in every browser?
Six per origin is the value in current desktop Chrome, Firefox, Safari and Edge, and mobile browsers use the same or fewer. The subtlety is what counts against it: prefetches, preloads and service-worker requests draw from the same pool, so the practical threshold can be reached with fewer than six streams.
Why do the queued requests never time out?
Because the browser has not attempted them yet. A request waiting for a connection slot has not been sent, so no server-side or network timeout applies β only the browser's own, which is generous. From the user's perspective the request simply hangs, and from the server's it never existed.
Does HTTP/2 completely remove the problem?
It removes the hard cliff. Streams are multiplexed over one connection, so the constraint becomes the server's advertised concurrent-stream limit, typically 100 to 128 rather than six. Applications that open a stream per component can still exhaust that, and corporate proxies that downgrade to HTTP/1.1 put those users back under the original limit.
Should I use a different subdomain for the stream instead?
It works β the limit is per origin, so stream.example.com has its own pool of six β but it costs you a second TLS handshake, cross-origin CORS configuration and cookie complications. Prefer HTTP/2 plus one multiplexed stream; reach for a separate origin only when you cannot enable HTTP/2.