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.
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.
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.
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.
Related #
- WebSockets Behind Corporate Proxies — why the primary transport fails.
- When to Use WebSockets over Server-Sent Events — SSE as a primary choice.
- Long Polling vs WebSockets — the fallback of last resort.
- Designing a WebSocket Message Envelope — the transport-independent protocol.
Back to Browser Compatibility & Polyfills.