Falling back from WebSockets to SSE #

Most of your users connect over WebSockets without trouble. A few percent — behind an inspecting corporate proxy, on a hotel network, inside a bank’s VDI — never get a working socket: the handshake hangs, fails with 1006, or connects and then dies after the first frame. For them your real-time features are simply broken, and support tickets say “the dashboard never updates”. Those networks almost always allow ordinary HTTPS, including long-lived streaming responses. Server-Sent Events for server-to-client messages, plus plain fetch POSTs for client-to-server messages, carry the same application protocol over infrastructure that does not understand Upgrade. A small transport abstraction lets the app switch between them without the rest of the code noticing.

Root cause #

WebSockets need every hop to handle an HTTP Upgrade and then pass arbitrary bidirectional frames for as long as the connection lives. Many security appliances and forward proxies do not: some strip the Upgrade header, some buffer responses for inspection, some only allow request/response traffic, and some terminate connections that do not look like HTTP after a timeout. The failure modes are inconsistent — the handshake may time out, return 400, or succeed and then stall — which is why naive code waits on a hanging connection for a long time before giving up. The details of these middleboxes are covered in WebSockets behind corporate proxies.

SSE is a standard HTTP GET with a text/event-stream response that the server keeps writing to. To a proxy it looks like a slow download. It is one-directional, but most real-time traffic is server-to-client anyway, and client-to-server messages can travel as ordinary POST requests, which every proxy allows.

Transport selection The client tries a WebSocket with a three-second deadline for the first message; on success it stays on WebSocket, on failure it switches to SSE plus POST, and periodically retries the WebSocket in the background. Transport selection Try WebSocket 3 s to first message WebSocket live full duplex SSE + POST fallback transport Retry upgrade later, in background welcome received timeout / failure after 10 min upgrade works Success is a received application message, not an open event — some proxies open and then stall
Fail over fast, and try to come back later.

Resolution #

Define one message protocol and a transport interface with two implementations. The client tries WebSocket with a short deadline measured to the first application message, not to the open event, because some middleboxes complete the handshake and then block frames. On failure it switches to SSE plus POST, remembers the decision for a while, and occasionally retries WebSocket in the background.

type Message = { type: string; seq?: number; [k: string]: unknown };
interface Transport {
send(msg: Message): void;
close(): void;
}

const WS_FIRST_MESSAGE_TIMEOUT_MS = 3_000; // welcome frame must arrive within this
const FALLBACK_MEMORY_MS = 10 * 60_000; // stay on SSE this long before retrying WS
const FALLBACK_KEY = 'rt-transport-fallback-until';

function wsTransport(url: string, onMessage: (m: Message) => void): Promise<Transport> {
return new Promise((resolve, reject) => {
const ws = new WebSocket(url);
const timer = setTimeout(() => { ws.close(); reject(new Error('ws: no welcome')); }, WS_FIRST_MESSAGE_TIMEOUT_MS);
ws.onmessage = (e) => {
const msg = JSON.parse(e.data) as Message;
if (msg.type === 'welcome') { clearTimeout(timer); resolve({ send: (m) => ws.send(JSON.stringify(m)), close: () => ws.close(1000) }); }
onMessage(msg);
};
ws.onerror = () => { clearTimeout(timer); reject(new Error('ws: error')); };
});
}

function sseTransport(base: string, sessionId: string, afterSeq: number, onMessage: (m: Message) => void): Transport {
// Server → client: one long-lived GET. EventSource reconnects on its own and sends
// Last-Event-ID, which the server uses to resume after the last delivered seq.
const es = new EventSource(`${base}/events?session=${sessionId}&afterSeq=${afterSeq}`, { withCredentials: true });
es.onmessage = (e) => onMessage(JSON.parse(e.data));
return {
// Client → server: plain POSTs through any proxy.
send: (m) => void fetch(`${base}/messages?session=${sessionId}`, {
method: 'POST', credentials: 'include', headers: { 'content-type': 'application/json' }, body: JSON.stringify(m),
}),
close: () => es.close(),
};
}

export async function connect(opts: { wsUrl: string; httpBase: string; sessionId: string; lastSeq: () => number; onMessage: (m: Message) => void }): Promise<Transport> {
const fallbackUntil = Number(sessionStorage.getItem(FALLBACK_KEY) ?? 0);
if (Date.now() > fallbackUntil) {
try {
return await wsTransport(`${opts.wsUrl}?session=${opts.sessionId}&afterSeq=${opts.lastSeq()}`, opts.onMessage);
} catch {
sessionStorage.setItem(FALLBACK_KEY, String(Date.now() + FALLBACK_MEMORY_MS));
}
}
return sseTransport(opts.httpBase, opts.sessionId, opts.lastSeq(), opts.onMessage);
}

On the server, both transports feed the same session: the WebSocket handler and the SSE handler read from the same per-session stream (a Redis Stream or an in-memory buffer), and both accept inbound messages into the same router. SSE’s built-in Last-Event-ID makes resumption natural — send each event with id: <seq> and the browser sends the last one back on reconnect.

// Server side of the SSE path (Express). Events carry id: seq for automatic resume.
app.get('/events', async (req, res) => {
res.set({ 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache, no-transform', 'X-Accel-Buffering': 'no' });
res.flushHeaders();
const after = Number(req.get('Last-Event-ID') ?? req.query.afterSeq ?? 0);
const unsubscribe = sessions.subscribe(String(req.query.session), after, (msg) => {
res.write(`id: ${msg.seq}\ndata: ${JSON.stringify(msg)}\n\n`);
});
const keepalive = setInterval(() => res.write(': keepalive\n\n'), 20_000); // comment line
req.on('close', () => { clearInterval(keepalive); unsubscribe(); });
});

X-Accel-Buffering: no and no-transform stop nginx and some proxies from buffering the stream, which would otherwise deliver events in delayed bursts. The comment-line keepalive plays the role WebSocket pings play, keeping idle proxies from closing the response.

The two transports, one protocol WebSocket and SSE with POST compared for server-to-client delivery, client-to-server messages, resumption, binary payloads and passing through strict proxies. The two transports, one protocol WebSocket SSE + POST Server → client frames event-stream Client → server same socket separate POSTs Resume afterSeq on connect Last-Event-ID Binary payloads native base64 in text Passes strict proxies often not yes Keeping one envelope format means only the transport layer knows which is in use
Same messages, same sequence numbers, different pipes.

Edge cases #

HTTP/1.1 connection limits. Browsers allow only about six concurrent HTTP/1.1 connections per origin, and each open EventSource holds one. Several tabs each with an SSE stream can starve the page’s other requests. Serve SSE over HTTP/2 (where streams share one connection), or share one stream across tabs as in sharing one WebSocket across browser tabs.

Ordering between POSTs. Separate POST requests can arrive out of order. If client messages must be ordered, number them and have the server apply them in sequence, or await each POST before sending the next.

Buffering proxies. Some inspecting proxies buffer even streaming responses. If events arrive in large delayed batches on the fallback, fall back further to long polling for that client — see long polling vs WebSockets.

Verification #

Simulate a hostile network by blocking upgrades at your own proxy for a test hostname (if ($http_upgrade) { return 403; } in nginx), then load the app: it should switch to SSE within the three-second deadline, and features should work normally — the Network panel shows an eventsource request streaming and fetch POSTs for outbound messages. Remove the block, wait out the fallback memory, and confirm the client returns to WebSocket.

In production, report the transport in use with other telemetry. The fallback share is a real number worth watching: a sudden rise usually means your own infrastructure (a new CDN rule, a WAF change) started breaking upgrades.

Falling back on a blocking network A corporate proxy stalls the WebSocket upgrade; after three seconds without a welcome message the client falls back to an event-stream GET, receives events with ids, and sends its own messages with POST. Falling back on a blocking network Client Corporate proxy Server WebSocket upgrade strips Upgrade / stalls no welcome in 3 s → fallback GET /events (event-stream) id: 812 data: … POST /messages The user waits three seconds once per session, not forever
A short deadline turns a broken transport into a brief delay.

Operational checklist #

FAQ #

Does Socket.IO do this automatically? #

Socket.IO falls back to HTTP long polling rather than SSE, and it starts with polling before upgrading. If you use Socket.IO, you get a fallback without building one; with raw WebSockets, the SSE path described here is a lighter, more efficient alternative to long polling.

Why wait for a message instead of the open event? #

Some middleboxes complete the upgrade handshake and then block or buffer frames, so open fires but nothing ever arrives. Treating the first application message as the success signal catches those cases.

Can SSE carry binary data? #

Not directly — SSE is a text format. Base64-encode binary payloads on the fallback path, accepting about a third more bytes, or use JSON for everything when the fallback is active.

How many users actually need the fallback? #

It varies widely by audience: consumer apps often see well under one percent, enterprise products sold into regulated industries can see several percent. Measure it with telemetry before deciding it is not worth supporting.

Back to Browser Compatibility & Polyfills.