⏱️ Lectura: 15 min

An HTTP endpoint can stay open for hours and send new data every time something changes, without the client ever asking again. That’s exactly what Server-Sent Events (SSE) do: an HTTP response that never finishes writing, read by the browser as a continuous stream of events through a single native API, EventSource.

📑 En este artículo
  1. TL;DR
  2. What Server-Sent Events Is and Why It Matters
  3. How It Works Under the Hood
  4. Practical Examples
  5. How to Get Started, Step by Step
  6. Real-World Use Cases
  7. Common Mistakes and Best Practices
  8. Comparison with Alternatives
  9. Going Deeper (Advanced)
  10. Frequently Asked Questions
    1. Does Server-Sent Events work behind a proxy like nginx?
    2. Can I send binary data over Server-Sent Events?
    3. What happens if the 6 connections per domain run out?
    4. How do I avoid duplicate events when reconnecting?
    5. Is Server-Sent Events useful for streaming language model responses?
    6. Do I need a library to use Server-Sent Events in the browser?
  11. References

Unlike WebSocket, there’s no need for a new protocol or an external library: it’s plain HTTP, with a different content type and a simple text format. Many live dashboards, notifications, and progress bars that currently use WebSocket could be handled with Server-Sent Events instead, with less code and less infrastructure to maintain.

TL;DR

  • You’ll understand how to keep an HTTP connection open to receive updates without polling.
  • You’ll build a Server-Sent Events server in Node.js with Express in under 20 lines.
  • You’ll handle automatic reconnection and the id field to resume the stream where it left off.
  • You’ll learn when SSE is the better choice over WebSocket, long polling, or Fetch with ReadableStream.
  • You’ll identify the 6-connections-per-domain limit in HTTP/1.1 and how to work around it.
  • You’ll debug the raw stream with curl -N without relying on the browser.
  • You’ll recognize the format used by APIs like Anthropic’s to stream tokens live.

What Server-Sent Events Is and Why It Matters

Think of it like tuning into a radio station: you connect once and then just listen to whatever gets broadcast, without picking up the phone every minute to ask if there’s a new song. That’s Server-Sent Events compared to traditional polling, where the client calls over and over asking if there’s anything new.

Server-Sent Events is an HTML5 specification, formalized in the WHATWG HTML Living Standard, that defines how a server can send updates to a web client over a single long-lived HTTP connection. The client makes a GET request, the server responds with Content-Type: text/event-stream, and instead of closing the response, it keeps writing to it in pieces as long as there’s something new to report.

The key piece on the browser side is EventSource, an API that has been natively available for over a decade in every modern browser. You open a connection with one line of code, listen for events with addEventListener, and the browser itself takes care of reconnecting if the connection drops. None of that comes for free with WebSocket: there, you have to write the reconnection logic, the message format, and the error handling yourself.

Server-Sent Events was standardized alongside WebSocket within the same batch of HTML5 APIs, but WebSocket got all the attention for enabling bidirectional communication. Server-Sent Events remained the lesser-known option, despite solving the most common case in a real application: the server announces, the client listens. Notifications, task progress, changing prices, logs being printed live, language model tokens generated one at a time.

How It Works Under the Hood

The Server-Sent Events data format is plain UTF-8 text, built around four fields: data, event, id, and retry. Each event is separated from the next by a blank line; that empty line is the delimiter that tells the browser the event ends there and needs to be processed.

data: hello from the server

event: pedido-actualizado
id: 42
data: {"estado":"listo"}

retry: 10000
data: reconnect in 10 seconds if you lose me

The data field is the only required one; if you don’t set event, the browser fires the generic message event. The id field is what makes recovery after a disconnect possible: every time the browser reconnects, it automatically sends the last id it saw in the Last-Event-ID header, and the server can use that value to resume the stream exactly where it left off instead of resending everything from scratch. The retry field tells the browser how many milliseconds to wait before retrying; if you don’t send it, the default value is around 3 seconds.

If the data value spans more than one line, it’s enough to repeat the field on each line: the browser concatenates them separated by line breaks before exposing them in evento.data.

data: first line
data: second line

That fragment arrives at the client as a single event where evento.data equals "first line\nsecond line". It’s the same mechanism used by language model streaming APIs to send partial text without waiting for the full response.

sequenceDiagram
participant B as Browser
participant S as Server
B->>S: GET /eventos with Accept text/event-stream
S-->>B: 200 OK Content-Type text/event-stream
loop while the connection stays open
S-->>B: data new event
end
Note over B,S: if the connection drops the browser reconnects on its own
B->>S: GET /eventos with Last-Event-ID 42

That automatic reconnection is why Server-Sent Events is so simple to use: the browser implements the retry and the resending of the last id without the developer having to write a single line of error-handling code for the common case.

Diagram of a Server-Sent Events flow between browser and server
The id field is what makes it possible to resume the stream after a network drop. Foto de Markus Spiske en Unsplash

Practical Examples

The smallest possible example uses Node.js’s http module with no dependencies:

const http = require('node:http');

http.createServer((req, res) => {
  if (req.url === '/eventos') {
    res.writeHead(200, {
      'Content-Type': 'text/event-stream',
      'Cache-Control': 'no-cache',
      'Connection': 'keep-alive'
    });

    res.write('data: hello from the server\n\n');

    setInterval(() => {
      res.write(`data: server time ${new Date().toISOString()}\n\n`);
    }, 5000);
  }
}).listen(3000);

This server responds to /eventos with the three headers the format requires, writes a first event, and then a new one every 5 seconds. The expected result: the connection never closes and a new line appears in the stream every 5 seconds.

The realistic version adds named events, an incremental id to allow resuming, and a heartbeat so intermediate proxies don’t consider the connection dead:

import express from 'express';

const app = express();
let ultimoId = 0;

app.get('/eventos', (req, res) => {
  res.set({
    'Content-Type': 'text/event-stream',
    'Cache-Control': 'no-cache',
    'Connection': 'keep-alive',
    'X-Accel-Buffering': 'no'
  });
  res.flushHeaders();

  const desdeId = Number(req.headers['last-event-id']) || 0;
  console.log('client reconnected from event', desdeId);

  const heartbeat = setInterval(() => res.write(':\n\n'), 15000);

  const enviarPedido = setInterval(() => {
    ultimoId += 1;
    const pedido = { id: ultimoId, estado: 'en_preparacion' };
    res.write(`id: ${ultimoId}\n`);
    res.write('event: pedido-actualizado\n');
    res.write(`data: ${JSON.stringify(pedido)}\n\n`);
  }, 4000);

  req.on('close', () => {
    clearInterval(heartbeat);
    clearInterval(enviarPedido);
  });
});

app.listen(3000);

The ':\n\n' line is a valid SSE comment (anything starting with a colon is ignored as data, but counts as traffic) and it’s used to keep the connection alive through load balancers and proxies that close idle sockets. The X-Accel-Buffering: no header is specific to nginx and prevents the proxy from batching several events before sending them, which would break the real-time feel.

On the browser side, consuming that stream looks like this:

const fuente = new EventSource('/eventos');

fuente.addEventListener('pedido-actualizado', (evento) => {
  const pedido = JSON.parse(evento.data);
  console.log(`order ${pedido.id} changed to status ${pedido.estado}`);
});

fuente.onerror = () => {
  console.log('connection lost, retrying, readyState:', fuente.readyState);
};

EventSource opens the connection on its own as soon as it’s instantiated. Since the server sends event: pedido-actualizado, you need to listen for that specific name with addEventListener instead of the generic message. If the server closes the connection, onerror fires, but there’s no need to reconnect manually: the browser is already doing it in the background.

How to Get Started, Step by Step

To try the Express example on your machine:

mkdir sse-demo && cd sse-demo
npm init -y
npm install express

Save the server code in server.js and start it:

node server.js

To verify the stream works without opening the browser, use curl with the -N flag (no-buffer), which confirms you’re seeing the events as they arrive instead of all at once at the end:

curl -N http://localhost:3000/eventos

You’ll see id:, event:, and data: lines appear every 4 seconds, plus an empty comment every 15 seconds from the heartbeat. If instead all the content arrives at once when you close the connection, something (your code or an intermediate proxy) is buffering the response and you need to check the headers.

To see it from the browser, open DevTools, go to the Network tab, make the request to /eventos, and select the “EventStream” sub-tab in Chrome (or “Response” in Firefox): each individual event shows up there with its timestamp, without needing to write any client code yet.

💡 Tip: send an SSE comment (a line starting with a colon) every 15 or 20 seconds as a heartbeat. Many load balancers and proxies close connections that have gone 30 or 60 seconds without traffic, and a heartbeat prevents that silent drop.
Architecture of a server emitting events via Server-Sent Events
A single Node.js server can sustain thousands of open SSE connections at once. Foto de Zach Lisko en Unsplash
flowchart TD
A["Browser (EventSource)"] --> B["Node.js or Express server"]
B --> C["Event generator"]
C --> D[("Message queue or database")]
subgraph Backend
B
C
D
end

Real-World Use Cases

The most common case is a progress bar or a status panel: a long-running task (processing a video, training a model, migrating a database) that reports its progress while it runs on the server. The client opens an EventSource when the task starts and that’s it, no need to keep asking every couple of seconds whether it’s done.

Another frequent case is in-app notifications: new message counters, alerts, order status changes, IoT sensor readings on a dashboard. All of that flows in a single direction, from server to client, which is exactly the model Server-Sent Events handles better than WebSocket. Live log tools use it too: a tail -f exposed over HTTP instead of a dedicated socket.

The most relevant case in 2026 is streaming responses from language models. Both Anthropic’s streaming messages API and OpenAI’s return the response token by token using the same text/event-stream format explained in this article: each new token arrives as an independent data: event, and the client concatenates them as they arrive. That’s why a chat with a language model shows text appearing word by word instead of waiting for the complete response.

Common Mistakes and Best Practices

The most common mistake is forgetting that an intermediate proxy (nginx, a load balancer, a CDN) can buffer the response before sending it to the client, which makes all the events arrive together at the end instead of in real time. The X-Accel-Buffering: no header solves this in nginx; other proxies have their own equivalent and you need to check their specific documentation.

⚠️ Watch out: if your server is behind nginx and the stream “works” but everything arrives all at once when the connection closes, the problem is almost always proxy buffering, not your code.

Another common mistake is not taking advantage of the id field. Without it, when the browser reconnects after a network drop, the server has no way to know which events were already delivered and ends up resending everything from the beginning or, worse, skipping events that got lost in between. An incremental id per event, stored on the server or in a queue with resume support, is what makes reconnection transparent to the user.

It’s also common to underestimate the simultaneous connections-per-domain limit imposed by HTTP/1.1: browsers allow up to 6 open connections per domain at a time. If you have several tabs of your app open, or several components that each open their own EventSource to the same domain, you can exhaust that limit and block other normal requests on the site.

In single-page applications, another common oversight is not closing the EventSource when a component unmounts or the user navigates to another view: the connection stays open in the background and orphaned sockets pile up. Calling fuente.close() in the component’s cleanup avoids that leak.

Finally, Server-Sent Events is strictly one-way. If the client needs to send something back (an ack, a command, a response), that part goes through a separate regular HTTP endpoint, with a conventional POST; SSE doesn’t replace that half of the problem, it only handles the server-to-client side.

Comparison with Alternatives

OptionWhen to use itAdvantageLimitation
Server-Sent EventsThe server pushes data and the client doesn’t need to respond over the same channelPlain HTTP, automatic reconnection, simple text formatOne-way, 6-connections-per-domain limit in HTTP/1.1
WebSocketChat, games, or any genuinely bidirectional, low-latency caseFull-duplex, supports binary dataSeparate protocol, no native automatic reconnection, another piece of infrastructure
Long pollingCompatibility with very old clients that don’t support streamingWorks in any browser and has for over 20 yearsMore HTTP requests, more latency, more server load
Fetch with ReadableStreamYou need to send custom headers or a method other than GET on the initial requestFull control over stream parsingYou have to manually reimplement reconnection and the event format

Going Deeper (Advanced)

The 6-connections-per-domain limit is a restriction of HTTP/1.1, not of the Server-Sent Events protocol itself. Over HTTP/2, all requests to the same origin are multiplexed over a single TCP connection, so that ceiling practically disappears: you can have dozens of SSE streams open to the same domain without blocking the rest of the traffic. Serving your application over HTTP/2 (most modern proxies enable it automatically when TLS is involved) is, in practice, the simplest fix for that problem.

Scaling Server-Sent Events across more than one process or server has an extra complication: each SSE connection is tied to the process that opened it. If your backend runs on several instances behind a load balancer, an event generated on instance A doesn’t automatically reach clients connected to instance B. The usual solution is a shared publish/subscribe channel (Redis Pub/Sub, a broker like NATS, or a queue) that each instance reads from and forwards to its own connected clients.

On the memory side, keeping thousands of SSE connections open is cheaper than it seems on a runtime with non-blocking I/O: Node.js, with its single-threaded event loop, sustains idle connections almost for free because there’s no OS thread blocked for each one. The same applies to Python with asyncio or Go with lightweight goroutines. The real cost lies in the amount of data actually flowing, not in the number of open sockets.

stateDiagram-v2
[*] --> CONNECTING
CONNECTING --> OPEN: connection established
OPEN --> CONNECTING: the server closes the stream
CONNECTING --> CLOSED: close() is called
OPEN --> CLOSED: close() is called
CLOSED --> [*]
💭 Key point: the readyState of an EventSource moves on its own from CONNECTING to OPEN and back to CONNECTING every time there’s a drop: the browser never gets stuck in a permanent error state unless you call close() yourself.

📖 Summary on Telegram: View summary

Your next step: clone the Express server from this article, run curl -N against /eventos to see the raw stream, then connect an EventSource from the browser console to compare what you see at each layer.

Frequently Asked Questions

Does Server-Sent Events work behind a proxy like nginx?

Yes, but you need to explicitly disable buffering with the X-Accel-Buffering: no header; without it, nginx batches the events before sending them and the real-time effect is lost.

Can I send binary data over Server-Sent Events?

Not directly: the format is UTF-8 text. For binary data you have to encode it as base64 inside the data field, which adds overhead, or use WebSocket if binary is the primary use case.

What happens if the 6 connections per domain run out?

Any other HTTP request to that same domain, including images or API calls, gets queued until a slot frees up. Serving the app over HTTP/2 eliminates that limit because it multiplexes everything over a single TCP connection.

How do I avoid duplicate events when reconnecting?

By using the id field on every event. The browser automatically resends the last id it received in the Last-Event-ID header, and the server needs to use that value to resume the stream instead of resending everything from the start.

Is Server-Sent Events useful for streaming language model responses?

Yes. Anthropic’s and OpenAI’s streaming APIs use exactly this format to return the response token by token as the model generates it.

Do I need a library to use Server-Sent Events in the browser?

No. EventSource is a native API available in every modern browser. You only need a polyfill if you need to support Internet Explorer.

References

📱 Enjoying this content? Follow @programacion on Telegram for daily tech content in Spanish: quick summaries, fresh content every day.

Imagen destacada: Foto de Growtika en Unsplash


Andrés Morales

Developer and AI researcher. Writes about language models, frameworks, developer tooling, and open source releases. Covers ML papers, the tech startup ecosystem, and programming trends.

0 Comments

Leave a Reply

Avatar placeholder

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.