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.

Direction of traffic in a typical dashboard WebSocket Bar chart of messages per minute by direction in a WebSocket-based dashboard, showing server-to-client traffic dominating. Direction of traffic in a typical dashboard WebSocket Server → client updates 240 / min Client → server subscribe/ack 3 / min Client → server pings 2 / min messages per minute per connected client
When the upstream is a trickle of subscribes and acks, a bidirectional socket buys little. SSE for the stream and HTTP for the trickle is simpler.

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.

What the client code loses and gains Two panels comparing the responsibilities of a hand-written WebSocket client with those of the SSE plus HTTP client that replaces it. What the client code loses and gains WebSocket client (removed) ping/pong heartbeat timers reconnect loop with backoff message envelope + op codes resubscribe after reconnect SSE + HTTP client (kept) addEventListener per type fetch() for client actions server-side subscription state Last-Event-ID resume, built in
Most of what disappears is infrastructure code the browser now provides. What remains is ordinary API calls the team already knows how to write.

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

A staged migration from WebSocket to SSE Timeline of eight weeks with the WebSocket path serving everyone, SSE shadowing for internal users, gradual percentage rollout, and the WebSocket path retired. A staged migration from WebSocket to SSE WebSocket path SSE path serving, shrinking internal 5 % → 100 % all users 0 1.6 3.2 4.8 6.4 8 weeks retire WS
Both paths publish from the same bus throughout. The flag, not a deploy, decides which one a user gets, so rollback is instant.

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.