In a single-page app that opens an EventSource when a dashboard route mounts, users report that after moving between routes for a while the page stops loading anything and new requests sit pending forever. How would you diagnose this, and what is the fix?
answer
- requests queue, server is healthy
- nothing closes an EventSource but close()
- one leaked stream per route visit
- six connections per origin over HTTP/1.1
- tabs share the same budget
basics
~20 sEach route visit opens a stream that nothing closes, and an EventSource stays open and self-reconnects until close() is called. Over HTTP/1.1 the leaked streams occupy the browser's roughly six connections per origin, so every other request queues.
solid answer
~50 sThe symptom — requests stuck in "Queueing" or "Stalled" with no bytes moving — points at connection exhaustion, not at the server. Confirm it in DevTools: filter for the stream endpoint and count how many are still pending; if you see one per route visit, they are leaking. The cause is that an `EventSource` does not stop when a component unmounts, when the variable is dropped or when listeners are removed — it holds its connection and re-establishes it after drops until something calls `es.close()`. Over HTTP/1.1 the browser allows only about six concurrent connections per origin, so a handful of zombies starves everything else on that origin, and it arrives sooner when the user has several tabs open. The fix is a guaranteed teardown path — `close()` in the cleanup — plus one shared stream for the app rather than one per component. Serving the endpoint over HTTP/2 raises the ceiling substantially, but it does not fix the leak.
code
javascript · 10 linesfunction subscribeOrders(onOrder) {
const es = new EventSource('/api/orders');
es.addEventListener('order', (event) => onOrder(JSON.parse(event.data)));
return () => es.close();
}
const unsubscribe = subscribeOrders((order) => console.log(order.id));
// Every construction site owns its teardown.
window.addEventListener('pagehide', unsubscribe);go deeper
Know that an EventSource keeps its connection open until close() is called, and that the cleanup belongs in whatever teardown hook created it.
Explain why leaked streams starve an origin: a browser allows only about six concurrent HTTP/1.1 connections per origin, and a stream never finishes, so ordinary requests queue behind them.
Diagnose from the waterfall — long Queueing with a healthy server — then prove the leak by counting pending stream requests, and fix it with guaranteed teardown plus a single shared stream rather than one per consumer.
Own the capacity picture: how many concurrent streams your service can hold, what each one costs in broker subscriptions and memory, whether HTTP/2 or HTTP/3 is available, and what guardrail stops the leak being reintroduced.
## Reading the symptom "Nothing loads and requests are pending" has a small number of causes, and the network waterfall separates them quickly. If each request shows a long **Queueing/Stalled** phase before any bytes move, while the server responds fast to the same URL from curl or another origin, the browser is not sending the request at all — it is waiting for a connection slot. That is a client-side resource problem, and long-lived responses are the usual culprit. The confirming observation is a count. Filter the Network panel to the stream endpoint, leave the panel recording while you navigate between routes, and watch pending stream requests accumulate one per visit. Six-ish of them and the origin is wedged. ## Why the streams accumulate An `EventSource` is a live object holding a live connection. It is not stopped by: - the component that created it unmounting; - the reference going out of scope — an object with an open connection and pending callbacks is reachable, so garbage collection will not save you; - removing your message listeners — that stops your handlers from running, not the transport; - navigating to another route in a single-page app, because there is no document unload. It is stopped by exactly one thing: `es.close()`. And because it also reconnects itself after any drop, a leaked stream is not even a passively idle socket — it actively re-establishes itself, which is why the problem does not heal after a network blip. ## Why six leaks are enough Over HTTP/1.1 a browser holds only around six concurrent TCP connections per origin. A normal page never notices, because ordinary requests finish and release their slot within milliseconds. A server-sent events stream is the opposite kind of request: it is deliberately never finished. Every leaked stream permanently occupies one of the small number of slots, and once they are all occupied, every subsequent request to that origin — API calls, images, the next chunk of JavaScript — sits queued. Two things make it worse in the field than in development. Multiple tabs share the per-origin budget, so a user with the dashboard open in three tabs burns three slots even with zero leaks. And the failure is *partial*: requests to a different origin (a CDN, a third-party API) still work, so the page looks half-alive, which sends people hunting in the wrong place. ## The fixes, in order **1. Guarantee teardown.** Every code path that constructs an `EventSource` must return or register the matching `close()`, and the cleanup must be tied to the lifecycle mechanism your framework already provides rather than to an ad-hoc callback. Write the subscription as a function that hands back an unsubscribe: ```js function subscribeOrders(onOrder) { const es = new EventSource('/api/orders'); es.addEventListener('order', (e) => onOrder(JSON.parse(e.data))); return () => es.close(); } ``` If the constructor and the `close()` are not visible in the same few lines, the leak will come back. **2. One stream, not one per consumer.** Several widgets on a dashboard each wanting live data should not mean several connections. Open one stream in a module-level singleton or a store, fan the messages out to subscribers in memory, and reference-count so the connection closes when the last consumer leaves. **3. Reduce the number of tabs holding a stream.** Because `EventSource` is available inside workers, one shared worker can hold a single stream and distribute messages to every tab; a simpler variant elects one tab as the stream owner and rebroadcasts to the others through `BroadcastChannel`. Shared-worker availability has historically been uneven across browsers, so the elected-owner approach is easier to ship. **4. Close when nobody is looking.** Listening for `visibilitychange` and closing a hidden tab's stream — reopening on return, with a snapshot refetch — removes most of the multi-tab pressure at the cost of a reconnect. **5. Serve the endpoint over HTTP/2 or HTTP/3.** There the per-origin cap is replaced by concurrent streams on one connection, typically around a hundred, so the ceiling stops being six. Treat this as raising the ceiling, not as the fix: leaked streams still consume server-side resources — a connection, memory, and often a database subscription or a message-broker consumer per client — and a leak that is invisible in the browser is still a capacity problem on the server. ## The check that keeps it fixed Add an assertion to the app's development build, or a smoke test, that navigating away leaves no pending stream request. Connection exhaustion is slow to appear and easy to reintroduce, and it never reproduces on the developer's machine, where nobody visits the same route forty times.
- Does dropping the reference to an EventSource let the browser clean it up?No. An EventSource with an open connection and registered listeners is still reachable and still doing work, so garbage collection does not apply and would not be a timely mechanism even if it did. The connection stays open and continues to re-establish itself after drops. `close()` is the only way to end it, which is why teardown must be tied to a lifecycle hook rather than left to the runtime.
- Moving the endpoint to HTTP/2 makes the symptom disappear. Is the bug fixed?No, it is hidden. HTTP/2 replaces the six-connection ceiling with concurrent streams on one connection, so the browser stops starving — but each leaked stream still holds server-side resources: a connection, memory, and usually a broker subscription or database listener per client. You have converted a visible client bug into an invisible capacity problem that shows up as server saturation under load instead.
- How would you serve one stream to a user who has five tabs open?Hold a single connection outside the tabs and fan out. Because EventSource is available in workers, a shared worker can own the stream and post messages to each tab; where shared-worker support is a concern, elect one tab as owner and rebroadcast through BroadcastChannel, with re-election when the owner closes. A cheaper partial measure is closing the stream while a tab is hidden and refetching a snapshot when it returns.
Your origin has about six parking spaces over HTTP/1.1. Each leaked stream parks in one and never leaves, so ordinary requests circle the block until a space frees up.
saying these in an interview costs you the question
- Blaming the server when requests are stuck in Queueing
- Assuming unmounting a component closes the stream
- Thinking garbage collection reclaims an open EventSource
- Opening one stream per widget on the same page
- Treating HTTP/2 as the fix rather than a higher ceiling