Skip to content

· javascript · 9 min read

A tech breakdown of Server-Sent Events vs WebSockets

Benefits and drawbacks of Server-Sent Events vs WebSockets, and when its better to use ach protocol based on your situation.

Neciu Dan

Neciu Dan

Hi there, it's Dan, Senior Engineering Manager at Perk, host of Señors at Scale - a podcast for Senior Engineers, Organizer of ReactJS Barcelona meetup, and international speaker, I'm here to share insights on combining technology and education to solve real problems.

I write about startup challenges, tech innovations, and the Frontend Development. Subscribe to join me on this journey of transforming education through technology. Want to discuss Tech, Frontend or Startup life? Let's connect.

Share:

You’ve probably used a chat AI by now. ChatGPT, Gemini, Claude, pick your poison. But I want to zoom into one specific thing about the experience: that typing effect when the response comes in, like someone on the other end is actually thinking and writing back to you.

Every AI chat product ships this exact interaction: you send a message, and the response materializes token by token in your browser.

That’s not a frontend animation. That’s a server writing to an open HTTP connection, one chunk at a time, and your browser rendering each chunk as it arrives.

And surprise-surprise it’s not WebSockets. It’s actually Server-Sent Events.

The default is wrong

Most devs reach for WebSockets the moment they hear “real-time.” It’s muscle memory at this point. Need a notification badge? WebSockets. Live dashboard? WebSockets. AI streaming response? WebSockets.

But if your data only flows in one direction (from server to client), you probably don’t need WebSockets. SSE does the job. EventSource is native to the browser. It auto-reconnects on connection drop. It works over plain HTTP. You don’t build any of that. It just happens.

The server side is almost comically simple:

app.get('/events', (req, res) => {
  res.writeHead(200, {
    'Content-Type': 'text/event-stream',
    'Cache-Control': 'no-cache',
  });

  setInterval(() => {
    res.write(`data: ${JSON.stringify({ time: new Date() })}\n\n`);
  }, 1000);
});
const source = new EventSource('/events');
source.onmessage = (event) => {
  console.log(JSON.parse(event.data));
};

That’s a working, real-time connection without a handshake, a protocol upgrade, or any dependencies.

The AI chat pattern works the same way. The prompt is sent via a regular HTTP POST, and the response is returned as an SSE stream. The server generates tokens one at a time, writes each one to the connection, and the client appends them to the page as they arrive. When the model finishes, the stream closes. If you open DevTools in ChatGPT, you’ll see WebSocket connections too (they use them for session management and other bidirectional features), but the actual token streaming that produces the typing effect is via SSE.

”But WebSockets are also native to the browser.”

Sure. WebSocket is a browser API too.

The difference is what you get for free. SSE auto-reconnects. The browser handles it. You can control the retry interval from the server by sending retry: 5000. There’s a built-in Last-Event-ID mechanism that lets the server resume where it left off after a reconnect. WebSockets give you none of this. You build reconnection yourself. Exponential backoff, jitter, the whole thing. Or you add Socket.IO, which adds a dependency, which adds a bundle size conversation, which eventually becomes a meeting about whether you really need Socket.IO.

The other historical objection to SSE was the browser limit on concurrent SSE connections per domain under HTTP/1.1: six concurrent connections per domain. Open a few tabs, and you’re done.

HTTP/2 killed this.

Multiplexing means multiple streams over a single TCP connection. If you tried SSE five years ago and bailed because of connection limits, try again. (This is actually another reason to use the fetch-based approach I’ll get to in a second, since native EventSource doesn’t always negotiate HTTP/2 cleanly depending on the server.)

Implementing SSE yourself is far easier than implementing decent WebSocket support. Less code on both sides and less overhead.

Ditch native EventSource

I showed you EventSource in the code example above. Don’t use it.

The native API has terrible error handling. You can’t get the response status code from the error event. You can’t set custom headers. You can’t send POST requests. It’s a GET-only API from a simpler time.

The move: SSE with fetch streams. Use fetch, read the response body as a stream, and parse the text/event-stream format with something like eventsource-parser. Server stays the same, but you get full control on the client.

import { createParser } from 'eventsource-parser';

const res = await fetch('/stream');
const reader = res.body.getReader();
const decoder = new TextDecoder();

const parser = createParser((event) => {
  if (event.type === 'event') {
    console.log(event.data); // parsed, no "data:" prefix
  }
});

while (true) {
  const { done, value } = await reader.read();
  if (done) break;
  parser.feed(decoder.decode(value));
}

The tradeoff is that you lose the automatic reconnection and Last-Event-ID that native EventSource gives you. You have to build that yourself. Proper reconnection with exponential backoff, jitter, ID tracking, and cleanup is more like 40-50 lines than trivial.

But in exchange you get proper error handling, custom headers, POST support, clean HTTP/2 negotiation, and the ability to actually know why a connection failed. I’ll take that deal.

Where SSE wins

AI streaming is the obvious one. Dashboards, stock tickers, notification feeds, deployment logs, progress bars, server metrics.

Anything where the server talks and the client listens.

SSE also plays nicely with existing HTTP infrastructure in ways WebSockets can’t. Your CDN understands it. Your caching headers work. Your monitoring tools parse it. You don’t need special proxy configuration beyond turning off buffering (more on that later). It’s just HTTP, and everything in your stack already knows how to deal with HTTP.

For 80% of “real-time” needs, SSE is more than enough.

Where WebSockets win

Multiplayer games, collaborative editors, and live chat with typing indicators. Anything with frequent, low-latency, bidirectional data. Binary data, too, since SSE is text-only and binary means Base64 with 33% overhead. (Though if you’re using the fetch stream approach, you can stream raw binary over HTTP without the SSE format at all. At that point, it’s not really SSE anymore, but the escape hatch exists.)

There’s a case for WebSockets specifically in AI chat: you keep the session alive on the same connection. Conversation context persists. Follow-up questions don’t re-establish anything. For products where the chat session is the product, that matters. The big AI providers chose SSE anyway, which tells you something about the tradeoff.

At the extreme end of the scale, WebSockets have an edge. SSE connections hold an HTTP response object in memory, and most implementations use the framework’s response writer, which isn’t optimized for tens of thousands of concurrent long-lived connections the way a purpose-built WebSocket server is.

Optimized WebSocket implementations like uWebSockets.js can push much higher concurrent connection counts on the same hardware. But you need engineers who understand epoll, buffer management, and backpressure to get there. SSE has a lower ceiling, but it’s harder to screw up.

The auth problem

I used to think auth with SSE was awkward because EventSource doesn’t support custom headers. You can’t just attach a Bearer token. The workaround was to pass the token as a query param, which felt gross.

The real issue is storing Bearer tokens in frontend JavaScript at all, regardless of protocol.

If you’re keeping tokens in localStorage and passing them via Authorization headers, the token lives in your JS runtime where it’s shared with other code, browser extensions, and every package in your supply chain. Any malicious dependency can call localStorage.getItem() and steal it.

With HttpOnly cookies, JavaScript can’t read the token. The browser manages it and decides when to send it.

I’ve seen this done wrong at large enterprises. 50-100K employee companies storing tokens in localStorage. It’s prone to injection.

Cookies aren’t a free pass either. You need CSRF protection (SameSite attributes, CSRF tokens), and if you’re using fetch-based SSE with credentials: 'include', you need to configure CORS properly on the server. But I’d rather configure CORS than leave tokens sitting in the JS runtime.

But HttpOnly cookies are a browser mechanism. Native mobile apps and third-party clients use bearer tokens. If your whole system assumes cookie auth, you’ll eventually refactor. Fair. But in the browser, cookies are the answer. SSE authenticates the same way as any other fetch request.

And if you need bearer tokens for a service-to-service SSE consumer that doesn’t involve a browser? That’s what the fetch-based approach is for:

const res = await fetch('/stream', {
  headers: { Authorization: `Bearer ${token}` }
});
const reader = res.body.getReader();

Shipping SSE in production

Nginx will buffer your events

Nginx buffers responses by default. Your SSE events are queued, and instead of real-time updates, users see nothing for 30 seconds, then a burst of stale data.

The fix:

location /api/stream {
    proxy_pass http://backend;
    proxy_buffering off;
    proxy_set_header Connection '';
    proxy_http_version 1.1;
    chunked_transfer_encoding off;
}

When you use the nginx defaults, connections drop after 60 seconds, and auto-reconnects flood the logs. You need to bump proxy_read_timeout and add heartbeat messages to keep connections alive. Once that’s sorted, SSE is way simpler than managing WebSocket state.

Load balancers and Cloud Run

Cloud Run is actually easier to set up for WebSockets than SSE. SSE requires custom configuration, especially with load balancers in front of your services. SSE is “just HTTP,” but long-lived HTTP connections still confuse infrastructure that doesn’t expect them.

Your framework might not support it

Most setups with Rails, Django, and sometimes PHP don’t work well with SSE because of their blocking nature and the lack of async in the default configuration.

If your server allocates one thread per connection and that connection stays open for minutes, you’re going to run out of threads fast. This is the same problem WebSockets have, just less obvious because SSE looks like “just an HTTP request” and people treat it like one.

Node.js handles this naturally with its event loop. Go handles it with cheap goroutines. FastAPI works well with uvicorn, but if your SSE handler performs synchronous I/O, you’ll block the event loop the same way Django would.

Make sure your async is actually async all the way down.

Message ordering and delivery guarantees

SSE’s auto-reconnect (whether native or hand-rolled with fetch) is nice until you need to handle message ordering or ensure delivery. Last-Event-ID helps in theory, but it only works if your server actually implements replay from a given ID.

Most SSE tutorials don’t cover this, which means most SSE deployments in the wild don’t benefit from Last-Event-ID at all.

It’s a feature in the spec that almost nobody uses. If you need guaranteed delivery with ordering, you need to build that layer on top, regardless of which protocol you pick.

Pick the right tool

I made a short video on this if you’d rather watch than read.

Start with SSE. Switch to WebSockets when you hit a concrete limitation, not a theoretical one. I’ve done the opposite. Defaulted to WebSockets because it felt like the serious choice, then spent weeks on complexity I didn’t need. It’s always a mistake.

Use fetch streams instead of native EventSource. Authenticate with cookies in the browser, bearer tokens in service-to-service. Turn off Nginx buffering. Add heartbeats. Ensure your framework supports long-lived connections.

It doesn’t get simpler than Content-Type: text/event-stream and a res.write().


References


I’ve been researching security attacks for months and turned it into a free course for frontend devs. It covers how the attacks work, defense layers, security tooling, building a custom scanner, container isolation, and incident response.

Module 1 is live at https://neciudan.dev/course/master-security. Enroll now!

Neciu Dan

Discover more from The Neciu Dan Newsletter

A weekly column on Tech & Education, startup building and occasional hot takes.

Over 1,000 subscribers

🎙️ Latest Podcast Episodes

Dive deeper with conversations from senior engineers about scaling applications, teams, and careers.

Hypermedia at Scale with Carson Gross
Episode 53
58 minutes

Señors @ Scale host Neciu Dan sits down with Carson Gross, creator of HTMX, principal at Big Sky Software, and computer science professor at Montana State University. Carson has been programming for more than 30 years, from HyperCard on a family Mac to Excel macros for an industrial engineering firm, a JVM language called Gosu, a Rails startup, and the 2012 library Intercooler.js that he rewrote without jQuery during COVID and released as HTMX. His argument is that the web was built as a hypermedia system, that links and forms are control logic living inside the document, and that most of what we call REST today fails the one constraint Roy Fielding cared about. He also covers which apps HTMX is wrong for, what changed in HTMX 4 and why 3 was skipped, and why his students now sit pencil and paper exams. It is a conversation about using the browser as the hypermedia client it was built to be.

📖 Read Takeaways
Performance at Scale with Ryan Townsend
Episode 52
52 minutes

Señors @ Scale host Neciu Dan sits down with Ryan Townsend, Principal Product Manager for Performance at Cloudflare, where he owns real user monitoring and the wider performance measurement remit. Ryan has over 20 years in web technology and a decade as a CTO: he co-founded Shift Commerce, the first enterprise scale headless SaaS, and was CTO of Matalan Direct after the business he built came second in Shopify's Build a Business competition with only three months of trading. We cover why the 'Rails doesn't scale' story was always a myth, the resume driven development he kept finding as a fractional CTO, the personalization script that claimed zero performance impact and cost seconds at P90, and how Shift ran over 99% cache hit ratios by splitting the anonymous page from the basket.

📖 Read Takeaways
Design Systems at Scale with Szymon Chudy
Episode 51
56 minutes

Señors @ Scale host Neciu Dan sits down with Szymon Chudy, Software Engineer at Netflix on the Hawkins team, which builds the design system behind thousands of internal applications used to produce the shows you watch. Before Netflix, Szymon spent two and a half years at Lokalise, growing from senior to staff frontend engineer and leading frontend direction across teams, and before that he toured Poland as a reggae guitarist while freelancing as a web developer in the off season. From why the source of truth for a design system lives in code, to Hawkins Consumer versus Hawkins Professional, to the UI paved road that changes what freedom and responsibility mean at Netflix, to measuring the team on adoption rate and shipping codemods and agent skills for every breaking change, to what being an informed captain who farms for dissent means in practice, this is the design system conversation for anyone maintaining a platform other engineers have to choose to use.

📖 Read Takeaways
Agentic Systems at Scale with Vilva Athiban
Episode 50
52 minutes

Señors @ Scale host Neciu Dan sits down with Vilva Athiban, Lead AI Engineer at Omio, one of Europe's leading travel platforms, where he architects and ships agentic AI systems in production. Vilva designed and delivered Omio AI, along with the MCP integrations that connect AI agents to the company's systems. He never set out to switch from JavaScript to AI: in June 2024 Omio picked two people to start an AI team reporting to the CEO and CTO, and he learned on the job, from his first API call to an LLM up to a consumer-facing agent whose backend runs on nothing but MCP. From why a bigger model with medium thinking came out cheaper than the mini, to capping tool calls because LLMs are built for token maxing, to the whale hunting request that showed his team guardrails are subjective, this is a conversation about what production agentic systems look like once the tutorials run out.

📖 Read Takeaways
Back to Blog