I built my first “real-time” feature in 2018 by reaching for socket.io because that’s what every tutorial used. The feature was a server-pushed notification banner. Pure server-to-client, one-way, maybe one message every five minutes. I’d just spent two days configuring sticky sessions on a load balancer for a feature that could’ve been an HTTP endpoint with a text/event-stream header and zero new infrastructure. I’m still a little embarrassed about it.
Since then I’ve shipped both protocols across enough Next.js dashboards, Laravel admin panels, and Go microservices to have a strong opinion. Short version for the impatient: if your data flows in one direction (server to client) and you can tolerate plain HTTP, use Server-Sent Events. If you genuinely need bidirectional, low-latency messaging, use WebSockets. The interesting part is everything in the middle.
What SSE actually is, and what it isn’t
SSE is a tiny protocol on top of plain HTTP. The server keeps a connection open and writes lines like data: hello\n\n whenever it wants to push something. The browser’s EventSource API listens. You can read the entire spec in the WHATWG HTML living standard and it’s about a page long.
The good parts: it works through every proxy that handles HTTP, it auto-reconnects when the network blips, it carries an event ID so the server can resume from where you left off, and it costs zero new infrastructure. You can serve it from the same Express, FastAPI, or Laravel route that serves the rest of your app. No upgrade handshake. No special load balancer rules. No “make sure the WebSocket port isn’t filtered.”
The bad parts: it’s text-only. The browser limits you to about six concurrent SSE connections per origin over HTTP/1.1. And the client cannot send messages back on the same connection. If your client needs to talk to the server, it makes a separate HTTP request. Not a problem for most use cases. A real problem for a chat app.
What WebSockets give you that SSE doesn’t
A WebSocket connection is bidirectional and binary-capable. After an HTTP upgrade handshake, both ends can write whenever they want. The framing is described in RFC 6455 and you should at least skim it once.
The actual reasons I reach for WebSockets:
- The client sends messages too. Multiplayer games, collaborative editors, chat. Anything where typing on one machine should immediately affect another.
- I need binary frames. Audio, video chunks, custom binary protocols. SSE is text-only, and base64-encoding binary over SSE is a sad way to live.
- I’m already on a stack with great WebSocket primitives. Phoenix Channels and Laravel Reverb come to mind. Fighting the framework to use SSE instead is rarely worth it.
WebSockets have hidden costs. You need a process that holds connections open, which means a real plan for restarts and deploys. You need to think about heartbeats because middleboxes love to kill idle TCP connections. And on serverless platforms, SSE routes through standard streaming responses but WebSockets need a different runtime entirely.
My actual decision tree
This is the thing I run through in my head, in this order.
Does the client need to send messages on the same connection? If yes, WebSockets. Stop here.
Is the data binary? If yes, WebSockets.
Am I building on serverless functions or edge runtimes? If yes, almost always SSE. The model fits the platform.
Is the average message rate under one per second per client? SSE is more than enough.
Do I need 10,000+ concurrent connections from a single box? Now I have to actually benchmark, and the answer often surprises me.
Most features land on SSE. I think a lot of teams default to WebSockets out of habit, the way I did in 2018.
The reconnect problem, where SSE quietly wins
Real networks are not the diagram in your architecture deck. Phones switch from wifi to LTE. Laptops sleep. NAT tables expire. Connections die without telling anyone.
SSE reconnects automatically. The browser stores the last event ID it received and sends it back as Last-Event-ID on the next request. Your server reads that header and resumes from the right point. You write basically zero client code for this. The MDN reference for EventSource covers exactly how the retry interval and last event ID work in practice.
WebSocket reconnect is your problem. You write the timer. You write the backoff. You write the server-side state recovery. You explain to your teammate why messages got delivered twice during a flap. There are libraries, but they’re papering over a thing the browser does for free with SSE.
I’ve seen “WebSocket flakiness” reported as a customer-facing bug enough times that I now treat reconnect logic as part of the cost of choosing WebSockets, not a footnote.
What I got wrong about scaling
For years I assumed SSE was “fine for small things, but won’t scale.” That was lazy thinking on my part.
Per-connection memory for an idle SSE stream on a tuned Go server is in the low kilobytes. The same is true for WebSockets. The bottleneck for both is usually file descriptors and the kernel’s TCP buffers, not the protocol overhead.
The HTTP/1.1 six-connection-per-origin limit is real, but if you’re on HTTP/2 or HTTP/3 (which you probably are if you’re behind any modern CDN), it goes away. Browsers multiplex streams over one TCP connection. The old “SSE doesn’t scale” advice is mostly leftover from 2015.
That said, WebSockets have a genuine edge for write-heavy workloads where every client also produces messages. A chat app with a thousand active typists is doing real work over each connection. SSE plus a separate POST endpoint per message can work, but the connection-per-write pattern starts to feel silly past a certain rate.
A real example: streaming LLM responses
The clearest case I’ve shipped is streaming text from an LLM to a browser. I covered the rest of the pipeline in my post on moving off raw OpenAI calls to the Vercel AI SDK, but the protocol choice is worth calling out separately.
The pattern is purely server to client. The model emits tokens, the server forwards them, the user reads them. Nothing flows the other way until the user types another prompt, which is a fresh request anyway. SSE fits this perfectly, and that’s why every major AI SDK ships SSE support out of the box.
Here’s the entire server side in a Next.js route handler:
export const runtime = 'edge'
export async function POST(req: Request) {
const { prompt } = await req.json()
const stream = await callModel(prompt)
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache, no-transform',
Connection: 'keep-alive',
},
})
}
And the client:
const es = new EventSource('/api/chat?prompt=hello')
es.onmessage = (e) => append(e.data)
es.onerror = () => es.close()
That’s the whole thing. No reconnect plumbing, no heartbeat loop, no upgrade handshake. If I tried to do this with WebSockets I’d add a hundred lines of client and server code for no benefit.
For comparison, the same feature over WebSockets looks like this on the client:
let ws: WebSocket | null = null
let retry = 1000
function connect() {
ws = new WebSocket('wss://example.com/chat')
ws.onmessage = (e) => append(e.data)
ws.onclose = () => {
setTimeout(connect, retry)
retry = Math.min(retry * 2, 30000)
}
ws.onopen = () => { retry = 1000 }
}
connect()
function send(prompt: string) {
ws?.send(JSON.stringify({ type: 'prompt', prompt }))
}
Reasonable code, but I’m now responsible for the reconnect, the backoff, and a message format. None of that earned its keep for a one-way stream.
When I do reach for WebSockets
Three projects in the last year, all genuine cases.
A collaborative whiteboard for a small team I worked with. Pointer positions, drawing strokes, presence updates, all bidirectional, all twenty times per second. WebSockets, with a lightweight CRDT on top.
An internal trading dashboard. The team executes trades themselves through their broker; my dashboard just visualizes positions. But the live position view used WebSockets because the same socket pushed user actions back to the server for audit logging. Bidirectional with strict ordering.
A Phoenix LiveView app where the framework already does WebSockets for me. Going against the grain there is a great way to lose a weekend.
If you’re curious about the kind of stack decisions I make on real projects, I keep a running list of the work I’m proud of. The pattern is usually “use the boring thing unless the boring thing fails a specific requirement.”
Try this in the next 30 minutes
Pick one feature in your app that currently uses long-polling, manual setInterval fetches, or a WebSocket that only ever sends server-to-client. Rewrite it as SSE. Time yourself. I’d bet most readers can do it in under an hour, and the diff will delete more code than it adds. If it ends up worse, I want to hear about it. That’s how I find the limits of my own opinion.