A send button that spins forever is not always a backend bug. If the request never appears in server logs, the browser may be waiting for a connection slot. This happens easily when a page opens many Server-Sent Events streams over HTTP/1.1.
The browser budget
HTTP/1.1 clients limit concurrent connections per origin. The common browser budget is small enough that six long-lived connections can starve the rest of the page. EventSource uses an HTTP request that stays open, so it consumes one of those slots for as long as the stream is active.
The dangerous pattern is one stream per item. A dashboard with twenty panes, cards, jobs, or devices can open twenty streams to the same origin. The first few connect. The rest sit pending. A laterfetch() for a button click queues behind them and appears to hang.
// Avoid: connection count grows with rows on the screen.
rows.map(row => new EventSource(`/api/rows/${row.id}/events`));How to diagnose it
Open DevTools, go to Network, filter by event-stream, and count pending requests to the same host. Then click the broken button. If the POST does not leave the browser, the server cannot log it. That absence is the clue.
Invert the stream
The fix is to make connection count independent of UI item count. Use one SSE stream that multiplexes events for all rows, one WebSocket, or one polling endpoint that returns the current state map.
// Better: one connection carries updates for all rows.
const events = new EventSource("/api/dashboard/events");
events.onmessage = (event) => {
const update = JSON.parse(event.data);
applyUpdate(update.id, update);
};Polling is not a failure. For small status payloads, a five-second poll can be more reliable and simpler than dozens of long-lived streams. The right question is not "streaming or polling?" It is "does the number of open connections scale with the number of visible entities?"
HTTP/2 changes the limit, not the design rule
HTTP/2 multiplexes many streams over one connection, so the exact symptom may disappear when the full path from browser to app is HTTP/2. But products move through proxies, staging stacks, and local dev servers. Designing one stream per entity still creates avoidable load and harder failure modes.
Treat long-lived browser connections as a scarce resource. Spend one per feature area, not one per row.