· web development · 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, a technical co-founder of an ed-tech startup, internation speaker and Staff Software Engineer, 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:
A tech breakdown of Server-Sent Events vs WebSockets

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!

From Lizard to Wizard Workshop

Engineering Excellence Workshop — Barcelona & Remote. Design Patterns, System Design, Security, Accessibility, Observability & more.

Join waitlist
    Share:
    Author

    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.

    Leveling Up as a Tech Lead with Anamari Fisher
    Episode 24
    52 minutes

    Señors @ Scale host Neciu Dan sits down with Anamari Fisher — engineering leader, coach, and O'Reilly author of 'Leveling Up as a Tech Lead' — to explore the first jump into leadership. Anamari shares how she went from software engineer to tech lead and product director, why accountability is the key differentiator from senior engineer, and how to scale your impact through soft skills that actually work in real teams.

    📖 Read Takeaways
    MicroFrontends at Scale with Florian Rappl
    Episode 23
    69 minutes

    Señors @ Scale host Neciu Dan sits down with Florian Rappl — author of 'The Art of Micro Frontends,' creator of the Piral framework, and Microsoft MVP — to explore how micro frontends are transforming how we build scalable web applications. Florian shares hard-won lessons from over a decade of building distributed systems, from smart home platforms to enterprise portals for some of Germany's largest companies.

    📖 Read Takeaways
    Nuxt at Scale with Daniel Roe
    Episode 22
    54 minutes

    Señors @ Scale host Neciu Dan sits down with Daniel Roe, leader of the Nuxt Core team at Vercel, for an in-depth conversation about building and scaling with Nuxt, Vue's most powerful meta-framework. Daniel shares his journey from the Laravel world into Vue and Nuxt, revealing how he went from being a user to becoming the lead maintainer of one of the most important frameworks in the JavaScript ecosystem.

    📖 Read Takeaways
    State Management at Scale with Daishi Kato (Author of Zustand)
    Episode 21
    35 minutes

    Señors @ Scale host Neciu Dan sits down with Daishi Kato, the author and maintainer of Zustand, Jotai, and Valtio — three of the most widely used state management libraries in modern React. Daishi has been building modern open source tools for nearly a decade, balancing simplicity with scalability. We dive deep into the philosophy behind each library, how they differ from Redux and MobX, the evolution of the atom concept, and Daishi's latest project: Waku, a framework built around React Server Components.

    📖 Read Takeaways
    Back to Blog