env.dev

Server-Sent Events: The Complete Guide to SSE vs WebSockets (with Node.js Examples)

Master Server-Sent Events (SSE): wire format, EventSource API, Node.js TypeScript implementation, auto-reconnection, HTTP/2 multiplexing, and a detailed comparison with WebSockets.

Last updated:

Server-Sent Events (SSE) is a standard HTTP-based protocol that lets a server push real-time updates to the browser over a single, long-lived connection. Unlike WebSockets, SSE uses plain HTTP — it works through every proxy, CDN, and load balancer without special configuration. The browser EventSource API handles reconnection automatically, resumes from the last received event via Last-Event-ID, and has 97.23% global browser support (Can I Use, 2026). SSE is the protocol behind AI streaming responses (ChatGPT, Claude), live dashboards, notification feeds, and any scenario where the server needs to push data and the client only needs to listen.

How Do Server-Sent Events Work?

The client opens an HTTP connection with Accept: text/event-stream. The server responds with Content-Type: text/event-stream and holds the connection open, writing events as UTF-8 text. Each event is a set of field lines separated by a blank line.

SSE connection flow
Browser                                    Server
  │                                            │
  │  GET /events                               │
  │  Accept: text/event-stream                 │
  │ ────────────────────────────────────────►   │
  │                                            │
  │  200 OK                                    │
  │  Content-Type: text/event-stream           │
  │  Cache-Control: no-cache                   │
  │  Connection: keep-alive                    │
  │  ◄────────────────────────────────────────  │
  │                                            │
  │  data: {"price": 142.50}\n\n               │
  │  ◄────────────────────────────────────────  │
  │                                            │
  │  data: {"price": 143.10}\n\n               │
  │  ◄────────────────────────────────────────  │
  │                                            │
  ×  Connection drops                          │
  │                                            │
  │  GET /events                               │
  │  Last-Event-ID: 42                         │
  │ ────────────────────────────────────────►   │
  │  (auto-reconnect after 3s)                 │

What Does the Wire Format Look Like?

The SSE wire format is defined by the WHATWG HTML spec. Messages are plain text with four field types:

FieldPurposeExample
data:Event payload (multiple lines concatenated with \n)data: {"status": "ok"}
event:Named event type (defaults to "message")event: price-update
id:Event ID for resumption via Last-Event-IDid: 42
retry:Reconnection delay in millisecondsretry: 5000
: commentIgnored by client — used for keep-alive pings: heartbeat

Messages are terminated by a blank line (\n\n). A complete event looks like:

Complete SSE event
id: 42
event: price-update
data: {"symbol": "AAPL", "price": 142.50}

id: 43
event: price-update
data: {"symbol": "AAPL", "price": 143.10}

: keep-alive comment (ignored by client)

How to Use EventSource in the Browser

The EventSource API is built into every modern browser. It handles connection management, automatic reconnection, and event parsing.

Basic EventSource usage
// Connect to SSE endpoint
const source = new EventSource('/api/events');

// Listen for default "message" events
source.onmessage = (event) => {
  const data = JSON.parse(event.data);
  console.log('Received:', data);
};

// Listen for named events
source.addEventListener('price-update', (event) => {
  const { symbol, price } = JSON.parse(event.data);
  updateTicker(symbol, price);
});

// Connection opened
source.onopen = () => {
  console.log('Connected, readyState:', source.readyState);
  // readyState: 0 = CONNECTING, 1 = OPEN, 2 = CLOSED
};

// Handle errors (browser auto-reconnects unless you close)
source.onerror = (err) => {
  if (source.readyState === EventSource.CLOSED) {
    console.log('Connection was closed by server');
  } else {
    console.log('Connection lost, reconnecting...');
  }
};

// Clean up when done
source.close();

For cross-origin SSE with cookies, pass { withCredentials: true } as the second argument to EventSource.

How to Build an SSE Server in Node.js (TypeScript)

An SSE server is just an HTTP endpoint that writes text/event-stream formatted data. No special libraries required.

Raw Node.js HTTP server

sse-server.ts — zero dependencies
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';

interface SSEClient {
  id: string;
  res: ServerResponse;
}

const clients = new Map<string, SSEClient>();

function sendEvent(res: ServerResponse, event: string, data: unknown, id?: string) {
  if (id) res.write(`id: ${id}\n`);
  res.write(`event: ${event}\n`);
  res.write(`data: ${JSON.stringify(data)}\n\n`);
}

function handleSSE(req: IncomingMessage, res: ServerResponse) {
  // Set SSE headers
  res.writeHead(200, {
    'Content-Type': 'text/event-stream',
    'Cache-Control': 'no-cache',
    'Connection': 'keep-alive',
    'X-Accel-Buffering': 'no', // disable nginx/reverse proxy buffering
  });

  // Set retry interval to 5 seconds
  res.write('retry: 5000\n\n');

  const clientId = crypto.randomUUID();
  clients.set(clientId, { id: clientId, res });

  // Resume from last event if reconnecting
  const lastId = req.headers['last-event-id'];
  if (lastId) {
    console.log(`Client ${clientId} reconnecting from event ${lastId}`);
    // Replay missed events from your event store here
  }

  // Keep-alive ping every 15 seconds
  const keepAlive = setInterval(() => res.write(': ping\n\n'), 15_000);

  // Clean up on disconnect
  req.on('close', () => {
    clearInterval(keepAlive);
    clients.delete(clientId);
    console.log(`Client ${clientId} disconnected (${clients.size} remaining)`);
  });
}

// Broadcast to all connected clients
function broadcast(event: string, data: unknown) {
  const id = Date.now().toString();
  for (const client of clients.values()) {
    sendEvent(client.res, event, data, id);
  }
}

const server = createServer((req, res) => {
  if (req.url === '/events') return handleSSE(req, res);
  res.writeHead(404).end();
});

server.listen(3000, () => console.log('SSE server on :3000'));

// Example: broadcast a timestamp every 2 seconds
setInterval(() => broadcast('tick', { time: new Date().toISOString() }), 2000);

Express + TypeScript

express-sse.ts
import express, { type Request, type Response } from 'express';

const app = express();

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

  // Send an initial event
  res.write(`data: ${JSON.stringify({ connected: true })}\n\n`);

  // Stream data
  const interval = setInterval(() => {
    res.write(`id: ${Date.now()}\n`);
    res.write(`event: update\n`);
    res.write(`data: ${JSON.stringify({ ts: Date.now() })}\n\n`);
  }, 1000);

  req.on('close', () => clearInterval(interval));
});

app.listen(3000);

Web-standard Response (Hono / Cloudflare Workers)

worker-sse.ts — works with Hono, Cloudflare Workers, Deno, Bun
function sseHandler(): Response {
  const encoder = new TextEncoder();

  const stream = new ReadableStream({
    start(controller) {
      let id = 0;

      const interval = setInterval(() => {
        const event = [
          `id: ${++id}`,
          'event: update',
          `data: ${JSON.stringify({ time: new Date().toISOString() })}`,
          '', // blank line terminates the event
          '',
        ].join('\n');

        controller.enqueue(encoder.encode(event));
      }, 1000);

      // Clean up if the client disconnects
      // (AbortSignal handling depends on runtime)
      setTimeout(() => {
        clearInterval(interval);
        controller.close();
      }, 60_000);
    },
  });

  return new Response(stream, {
    headers: {
      'Content-Type': 'text/event-stream',
      'Cache-Control': 'no-cache',
    },
  });
}

SSE vs WebSockets: When to Use Which?

SSE and WebSockets solve different problems. SSE is unidirectional (server → client) over standard HTTP. WebSockets are bidirectional over a separate ws:// protocol that requires an HTTP upgrade handshake. Choose based on your data flow — not because one is "better."

FactorSSEWebSocket
DirectionServer → client onlyBidirectional
ProtocolStandard HTTP/1.1 or HTTP/2ws:// / wss:// (HTTP upgrade)
Auto-reconnectBuilt-in (browser handles it)Must implement manually
Resume from last eventBuilt-in (Last-Event-ID header)Must implement manually
Data formatUTF-8 text onlyText and binary (ArrayBuffer, Blob)
Per-message overhead~100-200 bytes (HTTP text fields)2-14 bytes (binary frame header)
HTTP/2 multiplexingYes — shares connection with other requestsNo — separate TCP connection
Proxy/CDN/firewallWorks natively (plain HTTP)May require special configuration
Connection limit (HTTP/1.1)6 per domain (across all tabs)No browser-imposed limit
Connection limit (HTTP/2)~100 streams (negotiable)No browser-imposed limit
Browser support97.23% (no IE)98.4% (including IE 10+)
CORSStandard HTTP CORSOrigin header only (no preflight)

Use SSE when

  • The server pushes data and the client only listens (notifications, live feeds, AI streaming, dashboards)
  • You want automatic reconnection and event resumption without writing any code
  • You need to work through corporate proxies, CDNs, or load balancers without reconfiguration
  • You're on HTTP/2 and want to multiplex the event stream with other requests on the same connection

Use WebSockets when

  • Both client and server need to send data (chat, collaborative editing, multiplayer games)
  • You need to send binary data (file transfers, audio/video, game state)
  • Per-message overhead matters at high volume (WebSocket frames are 2-14 bytes vs SSE's ~100+ bytes of text)
  • You need more than 6 concurrent connections per domain on HTTP/1.1

Auto-Reconnection and Event Resumption

One of SSE's biggest advantages over WebSockets is built-in reliability. The browser handles reconnection automatically — you don't write retry loops.

Default retry delay

Browser waits 3 seconds before reconnecting. Server can override with retry: field.

retry: 5000

Last-Event-ID

On reconnect, browser sends the last received event ID as a header. Server can replay missed events.

Last-Event-ID: 42

Server opt-out

Responding with HTTP 204 tells the browser to stop reconnecting permanently.

HTTP/1.1 204 No Content

Manual close

Calling source.close() sets readyState to CLOSED and prevents any reconnection.

source.close()

Connection Limits and HTTP/2

On HTTP/1.1, browsers enforce a 6 connection limit per domain — this includes SSE connections. Opening SSE in multiple tabs can exhaust the limit, blocking other requests. This is marked "Won't Fix" in both Chrome and Firefox.

HTTP/2 solves this by multiplexing up to ~100 streams over a single TCP connection. Each SSE stream is just one multiplexed stream, leaving plenty of capacity for other requests. If you're deploying SSE in production, use HTTP/2.

Common Patterns and Best Practices

Keep-alive pings

Proxies and load balancers often kill idle connections after 30-60 seconds. Send comment lines as heartbeats:

typescript
// Server-side: send a comment every 15 seconds
const keepAlive = setInterval(() => {
  res.write(': ping\n\n');
}, 15_000);

Disable buffering

Reverse proxies buffer responses by default, preventing events from reaching the client in real time. Disable it:

ProxyHow to disable buffering
NginxX-Accel-Buffering: no header or proxy_buffering off;
ApacheNo buffering by default for text/event-stream
CloudflareAutomatically disabled for text/event-stream
AWS ALB/NLBNo buffering for streaming responses (ensure idle timeout > stream duration)

Graceful shutdown

typescript
// Track clients and clean up on server shutdown
process.on('SIGTERM', () => {
  for (const client of clients.values()) {
    client.res.end(); // closes connection, triggers browser reconnect
  }
  server.close();
});

Real-World Use Cases

AI/LLM Streaming

ChatGPT, Claude, and most AI APIs stream responses via SSE — each token arrives as a separate event.

Live Dashboards

Metrics, stock prices, and monitoring data pushed in real time without polling.

Notification Feeds

GitHub, Slack, and similar services push notifications to the browser via SSE.

CI/CD Build Logs

Stream build output line-by-line as it happens — naturally unidirectional.

Live Sports Scores

Score updates pushed to thousands of concurrent viewers over standard HTTP.

IoT Telemetry

Sensor data streamed to a browser dashboard — one-way data flow fits SSE perfectly.

Frequently Asked Questions

Can SSE send data from the client to the server?

No. SSE is unidirectional — server to client only. If you need to send data upstream, use a regular HTTP request (POST/PUT) alongside the SSE connection, or switch to WebSockets for full bidirectional communication.

Does SSE work with HTTP/2?

Yes, and it works better. HTTP/2 multiplexes SSE streams with other requests over a single TCP connection, eliminating the 6-connection-per-domain limit that exists on HTTP/1.1. This makes SSE practical for multi-tab applications.

How does SSE handle reconnection?

The browser automatically reconnects after a connection drop, waiting 3 seconds by default (configurable via the retry: field). On reconnect, it sends a Last-Event-ID header with the last received event ID, allowing the server to replay missed events.

Why would I choose SSE over WebSockets?

Choose SSE when the data flows one way (server to client). SSE gives you automatic reconnection, event resumption, HTTP/2 multiplexing, and works through all proxies and CDNs without configuration. WebSockets require you to implement all of this manually.

Can SSE send binary data?

No. SSE only supports UTF-8 text. For binary data, use WebSockets (which support ArrayBuffer and Blob) or encode the binary as Base64 — though this adds ~33% overhead.

What is the maximum number of SSE connections?

On HTTP/1.1, browsers limit you to 6 connections per domain across all tabs. On HTTP/2, the default is ~100 multiplexed streams per connection. Server-side, the limit depends on your infrastructure — each SSE client holds an open connection, so plan for concurrent connection capacity.

References