Migrating from WebSockets to SSE Permalink to this section
Part of SSE vs WebSockets vs HTTP Polling, under SSE Protocol Fundamentals & Architecture.
Many WebSocket features are WebSockets only because WebSockets were what the team knew. Look at the traffic and it is lopsided: the server pushes updates, and the client occasionally sends a subscribe message, an acknowledgement or a small action. For that shape, Server-Sent Events plus ordinary HTTP requests are simpler to operate — standard proxies, standard authentication, built-in reconnection and resume, and debuggable with curl. This guide migrates such a feature without downtime.
Symptom & Developer Intent Permalink to this section
Teams consider this migration when:
- WebSocket connections are dropped by corporate proxies or a load balancer that does not support upgrades well.
- The team maintains custom reconnection, heartbeat and resume code for the socket.
- Client-to-server messages are rare and would be simpler as normal API calls with existing auth and validation.
- Observability is weak: WebSocket messages bypass the HTTP request logging, tracing and rate limiting everything else uses.
The intent is to move the downstream to SSE and the upstream to HTTP, keeping behaviour identical for users and running both paths side by side until the switch is proven.
Root Cause Analysis Permalink to this section
The first step is to measure the traffic. If client-to-server messages are frequent, latency-critical or high-volume — games, collaborative drawing, voice — stay on WebSockets. If they are occasional, SSE plus HTTP is a good fit.
WebSocket applications usually implement, by hand, several things SSE provides: heartbeats (pings), reconnection with backoff, and resumption after a drop (often absent, so messages are lost). Moving to SSE removes code rather than adding it.
Step-by-Step Resolution Permalink to this section
Step 1 — Inventory message types by direction Permalink to this section
Write down every message type:
| WebSocket message | Direction | Becomes |
|---|---|---|
{"type":"price",…} |
server → client | event: price on the SSE stream |
{"type":"alert",…} |
server → client | event: alert with an id for replay |
{"op":"subscribe","symbols":[…]} |
client → server | query string on stream URL, or PUT /subscriptions |
{"op":"ack","id":…} |
client → server | POST /alerts/{id}/ack |
ping / pong |
both | SSE comment heartbeats; no client ping |
Server-to-client messages become named events. Client-to-server messages become HTTP endpoints, which inherit your existing authentication, validation, rate limiting and logging.
Step 2 — Handle subscriptions without a socket Permalink to this section
Subscriptions that change rarely go in the stream URL; reconnecting with a new URL is cheap because resume and snapshots make it seamless. Subscriptions that change often are held server-side, keyed by a stream id the server issues at connect:
// Server: tell the client its stream id first.
res.write(`event: hello\ndata: ${JSON.stringify({ streamId })}\n\n`);
// Client: change subscriptions with a normal request.
await fetch(`/api/streams/${streamId}/subscriptions`, {
method: 'PUT', credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ symbols: ['ACME', 'GLOBX'] }),
});
The server updates that stream’s filter; the next events reflect it. Store subscriptions with the stream id so a reconnect to another node restores them.
Step 3 — Add ids and replay Permalink to this section
WebSocket feeds rarely have resume. Add id: to every event that must not be lost and replay after Last-Event-ID on reconnect, as in implementing a replay buffer for Last-Event-ID. Users gain reliability the old implementation never had.
Step 4 — Hide the transport behind one client interface Permalink to this section
// realtime.js — the application only sees subscribe(), on(), send().
export function createRealtime({ transport = flags.sse ? 'sse' : 'ws' } = {}) {
return transport === 'sse' ? sseClient() : wsClient();
}
function sseClient() {
const es = new EventSource('/api/stream', { withCredentials: true });
return {
on: (type, fn) => es.addEventListener(type, (e) => fn(JSON.parse(e.data))),
send: (op, body) => fetch(`/api/realtime/${op}`, {
method: 'POST', credentials: 'include',
headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body),
}),
close: () => es.close(),
};
}
With both implementations behind the same interface, a feature flag selects the transport per user.
Authentication usually gets simpler too. A WebSocket often authenticates with a token in the first message, handled by bespoke code; SSE requests and the new HTTP endpoints use the same cookie or token scheme as the rest of the API, so the existing middleware applies unchanged. If the WebSocket carried a bearer token that EventSource cannot send as a header, move stream authentication to a cookie or a short-lived stream ticket as described in authenticating SSE streams with tokens and cookies.
Step 5 — Run both in parallel, then cut over Permalink to this section
Publish every event to both paths from the same bus. Roll out by percentage, comparing per cohort: delivery latency, reconnects per session, client errors and support tickets. Keep the WebSocket path until SSE has survived a deploy, a proxy change and a traffic peak.
Validation & Monitoring Permalink to this section
# Parity check: the same event appears on both transports within the same time window.
websocat wss://app.example.com/ws & curl -sN https://app.example.com/api/stream &
curl -s -X POST https://app.example.com/dev/publish -d '{"type":"price","sym":"ACME"}'
Watch server resources as well as user-facing metrics. SSE connections are ordinary HTTP requests, so they appear in request logs, tracing and rate limiting — which is part of the point — but those systems may need adjustment for requests that last hours: exclude stream requests from latency histograms built for short calls, and make sure access logs are written at stream start as well as at the end, so an open stream is visible before it closes.
Compare cohorts on a dashboard with side-by-side series. The migration is complete when SSE’s error and reconnect rates are at or below the WebSocket path’s, and no feature depends on a client-to-server message that lacks an HTTP equivalent.
Production Checklist Permalink to this section
Frequently Asked Questions Permalink to this section
Is SSE plus HTTP slower than a WebSocket for client actions?
Over HTTP/2 the request reuses the existing connection and headers are compressed, so a client action costs about one round trip, the same as a WebSocket message plus its acknowledgement.
What about the six-connection limit?
Serve over HTTP/2, where many streams share one connection. On HTTP/1.1, keep one SSE stream per page and share it across tabs.
When should I not migrate?
When clients send frequent, latency-critical or high-volume data — games, real-time drawing, audio — or when you need binary frames upstream. WebSockets fit those better.
Can the server still know when a client disconnects?
Yes. The SSE request closes when the client leaves, and heartbeats expose clients that vanished, exactly as with socket pings.