Why architecture matters here
The architecture matters because reachability, not elegance, decides whether a real-time feature works for a given user, and a chunk of real users cannot use the elegant transports. WebSocket is the right default — full-duplex, low-overhead, widely supported — but a meaningful fraction of clients sit behind proxies, corporate firewalls, or misconfigured intermediaries that block the upgrade or kill the connection. For those users a WebSocket-only design is simply broken, and broken silently: no exception, just updates that never arrive. A fallback ladder converts 'works for most' into 'works for essentially everyone', because long-polling's requirements are the lowest common denominator of the web — if a client can load a page at all, it can long-poll.
The second forcing function is that the transports must be interchangeable behind a single application interface, or the fallback is unmaintainable. If the application code has to know whether a given client is on WebSocket or long-polling, every feature branches three ways and the complexity is unbearable. The value of the architecture is that the hub presents one abstraction — 'send this message to this client', 'this client sent this message' — and hides which transport actually carries it. The negotiation picks the transport once, the hub adapts each transport to the common interface, and the application above never knows the difference. This uniformity is what makes supporting three transports cost roughly as much as supporting one.
The third reason is that fallback transports have gaps, and gaps lose messages unless the architecture explicitly prevents it. A WebSocket is continuously connected, but long-polling is a sequence of separate requests with tiny windows in between — after the server responds to one poll and before the client's next poll arrives, the connection is momentarily absent. Any message produced in that window would be lost if the server just fired-and-forgot. So the architecture must buffer: a per-client queue holds messages produced while the client is between polls, and a cursor (the id of the last message the client received) lets a reconnecting client say 'I last saw message 57, give me everything after', so the server replays 58 onward. Without this, long-polling drops messages at every poll boundary; with it, the gaps are invisible.
The fourth architectural payoff — and its main cost — is that these transports are stateful per client on the server, which shapes scaling. A held long-poll request or an open WebSocket ties a client to a specific server instance (the hub holding its queue and connection), so the load balancer must route that client's subsequent requests back to the same instance: session affinity. And a long-poll means the server is always holding one open, blocked request per connected client, consuming a connection slot and a bit of memory even when idle. At scale this is a real resource model — tens of thousands of held requests — that the server must be built to handle with non-blocking I/O, and that the capacity planning must account for. The elegance of long-polling on the wire is paid for in held connections on the server.
A fifth consideration is that the fallback decision must be fast and sticky, or users pay a visible penalty every time. If the client spends several seconds trying WebSocket, waiting for it to fail, then several more trying SSE before landing on long-polling, the feature feels broken during the probe even though it will eventually work. Good negotiation fails fast — a WebSocket that does not upgrade within a short budget, or that connects and then dies within a suspiciously short time, is abandoned quickly — and remembers the outcome so the next connection from the same client starts with the transport that worked rather than re-running the whole ladder. The architecture thus carries a small amount of memory about each client's network, turning a one-time discovery into a durable choice and keeping the fallback penalty to the first connection only.
The architecture: every piece explained
Top row: the negotiation ladder. A client connect wants live updates and begins at the top of the ladder. It tries WebSocket first — the full-duplex upgrade — and if that succeeds it uses it and stops. If the upgrade is blocked or the socket dies suspiciously fast, it falls to try SSE, a one-way server-push stream over ordinary HTTP that many environments allow even when they block WebSocket. If SSE too is buffered or blocked, it drops to the long-poll fallback: a held HTTP request the server answers only when it has a message. Each rung has strictly weaker requirements than the one above, so the client descends until it finds a transport its network permits.
Middle row: the server-side machinery that makes the fallback correct. The server hub holds the open connections and the held long-poll requests, presenting one uniform send/receive interface to the application regardless of transport. Behind each client sits a message queue — a per-client backlog that buffers messages produced while the client is momentarily between polls or reconnecting. The cursor / ack is the anti-loss mechanism: each client tracks the id of the last message it received, and on each new poll or reconnect it tells the server that id, so the server replays exactly the messages after it. The reconnect loop is the client behavior that keeps long-polling live — the instant a poll returns, the client immediately issues the next one, so a held request is almost always waiting.
Bottom row: the operational realities. Session affinity pins a client to the specific hub instance holding its queue and connection, so the load balancer must route the client's follow-up requests back to the same server — break this and the client's backlog and cursor are on a machine it no longer talks to. Held-connection cost is the price of the design: one open, mostly-idle request or socket per connected client, which demands non-blocking servers and real capacity planning. The ops strip names the health signals — the mix of clients on each transport (a rising long-poll share signals middlebox trouble), poll latency, the count of held connections, any missed-message incidents, and the reconnect rate that reveals connection churn.
End-to-end flow
Follow a client through negotiation and a long-poll session. The client opens the feature and immediately attempts a WebSocket upgrade to the server. It is behind a corporate proxy that strips the upgrade header, so the handshake does not complete within the short budget; the client abandons WebSocket and tries SSE. The same proxy buffers the SSE response, so the client sees no events arrive within the probe window and abandons SSE too. It falls to long-polling: it issues a plain HTTP GET to the poll endpoint carrying its cursor (last-seen message id 0). The server hub registers the client, finds its per-client queue empty, and holds the request open rather than answering immediately.
Now the application produces a message for this client. The hub finds the client's held request, writes the message as the HTTP response, and closes it — the client's poll returns instantly with the new message, feeling exactly like a push. The client processes the message, advances its cursor to that message's id, and immediately issues its next poll carrying the new cursor. The server again holds it open. Between the response and the next poll there is a millisecond-scale gap, but because the client re-polls immediately, a held request is waiting essentially all the time. This is the reconnect loop that makes a sequence of separate HTTP requests behave like a continuous stream.
Watch the gap-handling that prevents message loss. Suppose two messages are produced in quick succession right at a poll boundary — one just before the client's poll returns and one in the instant before its next poll arrives. The first is delivered in the returning response. The second is produced while no poll is held, so the hub enqueues it in the client's per-client queue. When the client's next poll arrives carrying the cursor, the server sees there is already a queued message after that cursor and answers immediately with it — no holding needed. The client never notices the gap; the queue plus cursor turned a momentary disconnection into a slightly-delayed delivery. If the client's network had dropped entirely and it reconnected seconds later with an old cursor, the same mechanism replays every message after that cursor, so a lengthy gap loses nothing either.
Finally, watch affinity and scale. The client's queue and cursor live on the specific hub instance that first registered it, so the load balancer routes every subsequent poll from this client — identified by a session cookie or token — back to that same instance. If the balancer instead spread the client's polls across instances, each would have a different, incomplete view of the client's queue and messages would be missed or duplicated. Across tens of thousands of clients, the server is holding tens of thousands of open poll requests at any instant, almost all idle, which is why it is built on non-blocking I/O that costs a cheap continuation per held request rather than a thread. As some clients' networks improve and they re-attempt WebSocket on their next connection — the negotiation is retried periodically — the transport mix shifts back toward WebSocket, and the long-poll load falls. The steady state is: try the best transport, fall back only as far as the network forces, buffer against the gaps, and pin each client to its hub.