Server-Sent Events: When WebSockets Are Overkill (And When They're Not)
Most teams reach for WebSockets when all they need is a long-lived GET request. Here's what I learned after choosing wrong multiple times.
An opinionated guide to choosing between Server-Sent Events and WebSockets, based on real production lessons. Learn what breaks, what scales, and what you'd do differently.
Jay McBride
Software Engineer
Introduction
I’ve watched teams spend weeks building WebSocket infrastructure for features that could have shipped in an afternoon with Server-Sent Events.
The pattern is always the same. Someone says “we need real-time updates,” and the next thing you know, you’re debugging connection pooling, implementing heartbeat logic, writing reconnection handlers, and fighting with load balancers that weren’t designed for WebSocket upgrades.
Six months later, you realize the client never sends data to the server. Not once.
This article is for developers who’ve already implemented WebSockets and are wondering why it felt so complicated. If you’re still learning what real-time communication is, this isn’t your starting point. Go read the MDN docs on EventSource first, then come back.
I’m going to tell you when Server-Sent Events are the right choice, when they absolutely aren’t, and what broke when I chose wrong. No “both technologies have their place” hedging. Just what I’d pick today for your actual use case.
Enjoying this? 👉 Tip a coffee and keep posts coming
Here’s who this is for: Mid-level to senior developers responsible for production systems. People who’ve seen WebSockets work and also seen them fail spectacularly. Solo founders who can’t afford to waste two weeks on infrastructure.
Not for: Beginners looking for a tutorial. This assumes you know what both technologies do.
The question isn’t “what are Server-Sent Events?” It’s “why did I pick WebSockets when SSE would have been fine?”
The Core Judgment: Default to SSE Unless You Need WebSockets
Here’s my default recommendation after shipping both approaches in production: start with Server-Sent Events and only switch to WebSockets when you hit a concrete limitation.
Not theoretical. Concrete.
Most teams do this backwards. They default to WebSockets because they feel more “professional” or “robust,” then spend weeks dealing with complexity they didn’t need. I’ve done this. Multiple times. It’s always a mistake.
WebSockets are complex infrastructure. You need proper heartbeat handling or you’ll get zombie connections. Your load balancer needs WebSocket-aware configuration or connections randomly die. You need manual reconnection logic with exponential backoff or you’ll DDoS your own servers during an outage. You need to handle partial message delivery because WebSockets can fragment frames.
SSE gives you most of this for free. Automatic reconnection. Standard HTTP routing. Built-in event parsing. No protocol upgrade handshake to debug.
The mistake people make is thinking bidirectional communication is a feature. It’s not. It’s a tradeoff. You get bidirectional capability in exchange for significantly more complexity. If you’re not using that capability, you paid for nothing.
I see this constantly: teams build WebSocket systems where the client sends maybe three messages during the entire session lifetime (connect, subscribe, disconnect). Everything else flows server-to-client. They could have used SSE for the stream and regular POST requests for those three client actions.
The decision isn’t “which is more powerful?” It’s “which complexity am I willing to maintain?”
If you genuinely need low-latency bidirectional streaming, use WebSockets. If you need the server to push updates and the client sends occasional commands, SSE is simpler, more reliable, and scales just fine.
How This Works in the Real World
The reason SSE feels weird at first is that it breaks your mental model of HTTP. You think HTTP is request-response. The server answers, connection closes, done.
But HTTP responses don’t have to end. You can set Content-Type: text/event-stream and just… keep writing. For minutes. Hours. Days.
Here’s what actually happens in production:
Client opens a GET request to /api/events. Server sends headers, client receives them, but the connection stays open. Server writes data: {some json}\n\n whenever something happens. Browser sees the double newline, parses it, fires an event. Connection stays open. Server writes more data later. Browser parses again. This continues indefinitely.
When the connection drops—network hiccup, server restart, whatever—the browser automatically reconnects. It sends the Last-Event-ID header if you’ve been setting IDs, so you can replay missed events. All of this is built-in browser behavior.
What surprised me when I first shipped SSE at scale:
Connections stay open for days. I thought they’d be fragile. They’re not. I’ve seen SSE connections running for 72+ hours without issues.
Load balancers mostly just work. As long as they support HTTP/1.1 chunked encoding (which is standard), SSE routes like normal HTTP. No special WebSocket upgrade configuration needed.
Memory usage is shockingly low. Each connection is just a response object and whatever state you attach to it. I’ve run thousands of concurrent SSE connections on a single Node.js process without issue.
HTTP/2 multiplexing changes everything. With HTTP/1.1, browsers limit you to 6 concurrent connections per domain. With HTTP/2, you can have dozens of SSE streams on a single TCP connection. The whole “connection limit” concern mostly disappears.
Why things fail:
The most common production failure isn’t the SSE protocol itself. It’s intermediate proxies buffering responses. Some reverse proxies or CDNs see a slowly-writing response and buffer it, thinking they’re being helpful. Suddenly clients get no data until the buffer fills or the connection times out.
Fix: Send periodic heartbeat comments (: keepalive\n\n). Tells the proxy “yes, this is intentionally slow, don’t buffer it.”
Second most common: Forgetting to clean up server resources when clients disconnect. You set up an interval to push updates, client disconnects, interval keeps running. Memory leak. Always handle the close event on the request object.
A Real Example: Live Deployment Status
I built a deployment dashboard that shows real-time build status for multiple services. CI/CD pipeline triggers, build starts, tests run, deployment happens—all pushed live to everyone watching.
What I built: Node.js endpoint that streams build events. Each connected client gets every event for projects they have access to. Permissions checked on connection, then events flow freely.
The setup:
Backend maintains a Map<projectId, Set<responseObjects>>. When a client connects, authenticate them, check project access, add their response object to the relevant project sets. When a build event happens, look up the project’s set, write to all response objects.
Scale: ~200 concurrent users during peak hours. Each user watching 3-5 projects on average. That’s roughly 800 active SSE connections. Single Node.js process handled it without breaking a sweat. CPU never exceeded 15%.
What surprised me:
Mobile browsers closing connections when the app backgrounds. I thought this would be a disaster. It wasn’t. Browser automatically reconnects when the app foregrounds. Because I’m using event IDs, clients get any missed updates on reconnect. Felt broken initially, turned out fine.
What I’d change today:
I’m multiplexing all project updates over a single SSE connection per client. This works, but means the client gets events for all their projects mixed together and has to filter client-side.
If I rebuilt it, I’d probably keep this approach. The alternative—one SSE connection per project—hits browser connection limits on HTTP/1.1 and adds complexity. The filtering overhead is negligible.
But here’s what I would change: I’d add proper backpressure handling. Right now if a client’s connection is slow (bad mobile network), we just buffer writes. Eventually the buffer fills and the connection errors. Better approach: detect slow clients, maybe send them reduced-frequency updates or drop them entirely with a clear error message.
Common Mistakes I Keep Seeing
Sending events faster than clients can handle
I see this in dashboards that update every 100ms. If your update frequency is higher than the time it takes to render the update, you’re creating lag. Either batch updates on the server or throttle rendering on the client.
I made this mistake on a real-time analytics dashboard. Sent every individual click event as it happened. 100 events per second. Client spent all its time parsing JSON and updating the DOM. UI locked up.
Fix: Server batches events into 500ms windows, sends one aggregated update. UI stays responsive.
Treating SSE like it’s guaranteed delivery
It’s not. It’s best-effort over TCP. If a client disconnects between messages, they miss those events unless you implement replay logic using event IDs.
For critical workflows, you need a real queue. SSE is for UI updates that can tolerate occasional misses, not financial transactions.
Not setting the retry field
By default, browsers reconnect immediately on failure. During a server outage, this creates a reconnection storm. Thousands of clients hammering your server the moment it comes back up.
Set retry: 10000 in your SSE messages. Tells browsers to wait 10 seconds before reconnecting. Spreads the load during recovery.
Using SSE for binary data
You can base64 encode binary data and send it over SSE. Don’t. The overhead is brutal (33% size increase) and you’re fighting the protocol’s design. Use WebSockets or regular HTTP downloads.
Forgetting CORS headers
SSE is HTTP. CORS rules apply. If your frontend and API are on different origins, you need Access-Control-Allow-Origin. Sounds obvious, but I’ve watched people debug this for hours.
Assuming all proxies support streaming
Some enterprise proxies or CDNs kill long-lived connections or buffer responses aggressively. Always test in your actual deployment environment. Cloudflare specifically requires configuration to allow streaming.
Tradeoffs and When This Breaks Down
SSE can’t send binary efficiently
If you need to stream video frames, audio chunks, or large binary data, WebSockets or WebRTC are better. SSE is text-only. You can encode binary as base64 but the 33% overhead kills you at scale.
One-way communication becomes a liability for chatty clients
If your client needs to send frequent updates to the server (every keystroke, mouse movement, sensor data), SSE feels backwards. You’re maintaining an SSE stream for server-to-client and making constant HTTP POST requests for client-to-server.
At that point, a single bidirectional WebSocket connection is simpler.
Browser connection limits on HTTP/1.1
Six concurrent connections per domain. If you need multiple SSE streams and you’re stuck on HTTP/1.1, you’ll hit this limit. HTTP/2 solves this completely, but not all environments support it.
Workaround: Multiplex multiple event types over one SSE connection. Less elegant but works.
The EventSource API doesn’t support custom headers
You can’t set Authorization: Bearer <token> on an SSE connection using the native EventSource API. Your options:
- Send the token as a query parameter (less secure, visible in logs)
- Use cookies with
withCredentials: true(best option) - Use a polyfill that wraps fetch/XHR (adds complexity)
I default to HTTP-only cookies for SSE auth. Works well, standard security model.
Memory usage at extreme scale
Each SSE connection keeps a response object open in memory. At 100,000+ concurrent connections, this becomes a real resource consideration. WebSockets use less memory per connection in extremely high-scale scenarios because they’re stateless after the upgrade.
But if you’re at that scale, you’re not reading this article. You’re already running specialized infrastructure.
Mobile background behavior
iOS and Android browsers close connections when apps background. This is true for both SSE and WebSockets. Not an SSE-specific limitation, but worth knowing. Your clients need reconnection logic when the app foregrounds.
Best Practices I Actually Follow
Send heartbeats every 30-45 seconds
Keeps the connection alive through proxies and lets you detect dead clients. Format: : heartbeat\n\n (SSE comment syntax).
Use event IDs for anything users care about missing
If the event matters, tag it with an incrementing ID. Browser sends Last-Event-ID on reconnect. Your server can replay missed events.
I don’t do this for real-time analytics (missing a few data points is fine), but I do for notifications (users notice missing notifications).
Structure messages consistently
Every SSE message I send follows the same JSON structure:
{
"type": "eventType",
"timestamp": "2026-01-10T10:00:00Z",
"data": { /* event-specific */ }
}
Client-side code can handle any event generically, then route by type. Clean.
Close connections from the server when appropriate
If a user’s session expires or permissions change, close the SSE connection server-side. Don’t let them keep receiving events they shouldn’t see.
Monitor connection counts and message throughput
Track active SSE connections as a metric. Spikes often indicate issues (reconnection storms, someone polling when they should stream, bot traffic).
Compress the stream
Enable gzip compression on your SSE endpoint. Text compresses extremely well. I’ve seen 10x bandwidth reduction on JSON-heavy streams.
Keep event payloads small
Don’t send entire objects if you can send IDs. Client can fetch details separately if needed. Keeps the stream fast and reduces the cost of dropped messages.
Test reconnection behavior explicitly
Kill your server mid-stream and watch what happens. Does the client reconnect? Does it get missed events? Does it flood the server with reconnection attempts?
You’ll find issues this way that never appear in normal testing.
Conclusion
WebSockets aren’t wrong. They’re just overkill most of the time.
If you need bidirectional streaming, use WebSockets. If you need to send binary data efficiently, use WebSockets. If you’re building multiplayer games or collaborative editing, use WebSockets.
For everything else—live dashboards, notifications, activity feeds, server logs, deployment status—SSE is simpler, more reliable, and easier to debug.
The real cost of choosing WebSockets when SSE would have worked is the maintenance burden. You’re signing up for connection pooling logic, heartbeat implementation, manual reconnection handling, and load balancer configuration that SSE gives you for free.
I default to SSE. If I hit a limitation that genuinely requires WebSockets, I’ll switch. But that’s happened maybe twice in the last five years.
Most real-time features are simpler than you think. Start simple.
Frequently Asked Questions (FAQs)
What if I need to send data from the client occasionally, not constantly?
Use SSE for the server-to-client stream and regular HTTP POST/PUT for client-to-server commands. This is completely normal and works well. The client sends maybe 5-10 requests during a session, receives hundreds of events. That’s perfect SSE territory.
Do SSE connections count against my server’s connection limit?
Yes, but so do WebSockets. Every persistent connection uses a file descriptor. The practical limit on a modern Linux server is in the tens of thousands of concurrent connections before you need to tune kernel parameters. Most applications never get close to this.
Can I use SSE with serverless functions like AWS Lambda?
No. Serverless functions are designed for short-lived requests. SSE connections stay open indefinitely. Use traditional servers (EC2, containers, VPS) or managed services designed for persistent connections.
How do I handle authentication for SSE connections?
Best approach: HTTP-only cookies with withCredentials: true on the EventSource. Second best: query parameter tokens (less secure but works). Worst: giving up and making the endpoint public (please don’t).
What happens when my server restarts or deploys?
All SSE connections drop. Browsers automatically reconnect. If you’re using event IDs, clients get any missed events. If you’re not, they start receiving new events immediately. Design your system so a connection drop isn’t catastrophic.
Is SSE more efficient than polling?
Massively. Polling makes a new HTTP request every N seconds (new connection, headers, handshake, tear down). SSE opens one connection and keeps it alive. I’ve seen 80% reduction in server load switching from 5-second polling to SSE.
Your turn: Look at your current WebSocket implementation. Could 80% of it be replaced with SSE and regular POST requests? What’s actually bidirectional versus what’s just notifications?
Enjoying this? 👉 Tip a coffee and keep posts coming
