Skip to main content
Technology & EngineeringWebsocket266 lines

Server Sent Events

Server-Sent Events (SSE) patterns for efficient unidirectional real-time streaming from server to client

Quick Summary18 lines
You are an expert in Server-Sent Events (SSE) for building real-time applications with server-to-client streaming.

## Key Points

- `data:` — the event payload (can span multiple lines)
- `event:` — a named event type (defaults to `"message"`)
- `id:` — an event identifier for resumption
- `retry:` — reconnection delay in milliseconds
- **Use SSE when communication is primarily server-to-client** — it is simpler than WebSockets and works through all HTTP infrastructure without special configuration.
- **Always include event IDs** so clients can resume after disconnection without missing events.
- **Set `retry:` to control reconnection timing** — the default varies by browser. Send `retry: 3000\n\n` to set a 3-second delay.
- **Disable response buffering** at every layer — set `X-Accel-Buffering: no` for Nginx, `Cache-Control: no-cache`, and disable any compression for the event stream.
- **Use named events** to multiplex different data types over a single connection rather than opening multiple EventSource instances.
- **Send periodic comments** (lines starting with `:`) as heartbeats to keep the connection alive through proxies and load balancers.
- **Missing `Content-Type` header** — if the server does not respond with `text/event-stream`, the browser rejects the connection silently.
- **Proxy buffering** — reverse proxies like Nginx buffer responses by default, causing events to arrive in bursts. Disable buffering explicitly.
skilldb get websocket-skills/Server Sent EventsFull skill: 266 lines
Paste into your CLAUDE.md or agent config

Server-Sent Events — WebSockets & Real-Time

You are an expert in Server-Sent Events (SSE) for building real-time applications with server-to-client streaming.

Overview

Server-Sent Events (SSE) is a standard for pushing updates from a server to a browser client over a single, long-lived HTTP connection. Unlike WebSockets, SSE is unidirectional (server to client only), uses plain HTTP, and includes built-in reconnection and event ID tracking. SSE is ideal for live feeds, notifications, progress updates, and any scenario where the client primarily consumes data.

Core Concepts

The SSE Protocol

SSE uses a simple text-based protocol over HTTP. The server responds with Content-Type: text/event-stream and sends events as newline-delimited text blocks. Each event can have:

  • data: — the event payload (can span multiple lines)
  • event: — a named event type (defaults to "message")
  • id: — an event identifier for resumption
  • retry: — reconnection delay in milliseconds

Events are separated by a blank line (\n\n).

Automatic Reconnection

The EventSource API automatically reconnects when the connection drops. On reconnect, it sends the Last-Event-ID header with the last received id, allowing the server to resume from where the client left off.

Differences from WebSockets

FeatureSSEWebSocket
DirectionServer to clientBidirectional
ProtocolHTTPWebSocket (ws/wss)
ReconnectionBuilt-inManual
Binary dataNo (text only)Yes
Browser supportAll modern browsersAll modern browsers
Proxy friendlinessExcellentSometimes problematic

Implementation Patterns

Basic Server (Node.js / Express)

import express from 'express';

const app = express();

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

  // Send initial connection event
  res.write('event: connected\ndata: {"status":"ok"}\n\n');

  // Send periodic updates
  const intervalId = setInterval(() => {
    const data = JSON.stringify({ time: new Date().toISOString(), value: Math.random() });
    res.write(`data: ${data}\n\n`);
  }, 1000);

  // Clean up on client disconnect
  req.on('close', () => {
    clearInterval(intervalId);
    res.end();
  });
});

app.listen(3000);

Browser Client with EventSource

const source = new EventSource('/events');

// Default "message" events
source.addEventListener('message', (event) => {
  const data = JSON.parse(event.data);
  console.log('Message:', data);
});

// Named events
source.addEventListener('connected', (event) => {
  console.log('Connected:', JSON.parse(event.data));
});

source.addEventListener('notification', (event) => {
  const notification = JSON.parse(event.data);
  showNotification(notification);
});

// Connection state
source.addEventListener('open', () => {
  console.log('SSE connection opened');
});

source.addEventListener('error', (event) => {
  if (source.readyState === EventSource.CLOSED) {
    console.log('SSE connection closed permanently');
  } else {
    console.log('SSE reconnecting...');
  }
});

Event IDs and Resumption

// Server: include event IDs
let eventCounter = 0;

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

  // Resume from last event if reconnecting
  const lastId = parseInt(req.headers['last-event-id'] || '0', 10);
  console.log(`Client reconnected, last event ID: ${lastId}`);

  // Replay missed events from a buffer or database
  const missedEvents = getEventsSince(lastId);
  for (const event of missedEvents) {
    res.write(`id: ${event.id}\ndata: ${JSON.stringify(event.data)}\n\n`);
  }

  // Continue streaming new events
  const handler = (event) => {
    eventCounter++;
    res.write(`id: ${eventCounter}\ndata: ${JSON.stringify(event)}\n\n`);
  };

  eventEmitter.on('update', handler);
  req.on('close', () => eventEmitter.off('update', handler));
});

SSE with Authentication (fetch-based)

// EventSource does not support custom headers.
// Use fetch + ReadableStream for authenticated SSE.

async function connectSSE(url, token) {
  const response = await fetch(url, {
    headers: { Authorization: `Bearer ${token}` },
  });

  if (!response.ok) throw new Error(`SSE failed: ${response.status}`);

  const reader = response.body.getReader();
  const decoder = new TextDecoder();
  let buffer = '';

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

    buffer += decoder.decode(value, { stream: true });
    const lines = buffer.split('\n');
    buffer = lines.pop(); // Keep incomplete line in buffer

    let eventType = 'message';
    let data = '';

    for (const line of lines) {
      if (line.startsWith('event: ')) {
        eventType = line.slice(7);
      } else if (line.startsWith('data: ')) {
        data += line.slice(6);
      } else if (line === '') {
        if (data) {
          handleEvent(eventType, JSON.parse(data));
          eventType = 'message';
          data = '';
        }
      }
    }
  }
}

Multi-Channel SSE

// Server: subscribe to multiple channels via query params
app.get('/events', (req, res) => {
  res.writeHead(200, {
    'Content-Type': 'text/event-stream',
    'Cache-Control': 'no-cache',
    'Connection': 'keep-alive',
  });

  const channels = (req.query.channels || '').split(',').filter(Boolean);

  const handlers = channels.map((channel) => {
    const handler = (data) => {
      res.write(`event: ${channel}\ndata: ${JSON.stringify(data)}\n\n`);
    };
    pubsub.subscribe(channel, handler);
    return { channel, handler };
  });

  req.on('close', () => {
    handlers.forEach(({ channel, handler }) => {
      pubsub.unsubscribe(channel, handler);
    });
  });
});

// Client
const source = new EventSource('/events?channels=orders,inventory,alerts');

source.addEventListener('orders', (e) => updateOrders(JSON.parse(e.data)));
source.addEventListener('inventory', (e) => updateInventory(JSON.parse(e.data)));
source.addEventListener('alerts', (e) => showAlert(JSON.parse(e.data)));

Best Practices

  • Use SSE when communication is primarily server-to-client — it is simpler than WebSockets and works through all HTTP infrastructure without special configuration.
  • Always include event IDs so clients can resume after disconnection without missing events.
  • Set retry: to control reconnection timing — the default varies by browser. Send retry: 3000\n\n to set a 3-second delay.
  • Disable response buffering at every layer — set X-Accel-Buffering: no for Nginx, Cache-Control: no-cache, and disable any compression for the event stream.
  • Use named events to multiplex different data types over a single connection rather than opening multiple EventSource instances.
  • Send periodic comments (lines starting with :) as heartbeats to keep the connection alive through proxies and load balancers.

Common Pitfalls

  • Browser connection limit — browsers limit concurrent HTTP connections per domain (typically 6 for HTTP/1.1). SSE connections count toward this limit. Use HTTP/2 to avoid this, as it multiplexes streams over a single connection.
  • Missing Content-Type header — if the server does not respond with text/event-stream, the browser rejects the connection silently.
  • Proxy buffering — reverse proxies like Nginx buffer responses by default, causing events to arrive in bursts. Disable buffering explicitly.
  • Not cleaning up on disconnect — failing to remove event listeners or clear intervals when req.on('close') fires leads to memory leaks and errors from writing to closed streams.
  • No authentication storyEventSource does not support custom headers. Use cookies, query-string tokens, or a fetch-based approach for authenticated streams.
  • Assuming ordered delivery across reconnects — if the server does not track event IDs and replay missed events, clients will have gaps after reconnection.

Core Philosophy

SSE is the right tool when communication is primarily server-to-client. It is simpler than WebSockets, uses plain HTTP that works through every proxy and firewall, and includes built-in reconnection with event ID resumption — features you would have to implement manually with WebSockets. If your use case is live feeds, notifications, progress updates, or real-time dashboards where the client only consumes data, SSE should be your default choice.

Event IDs are what make SSE reliable. By including an id: field with every event, you enable the browser's automatic reconnection to resume from the last received event. When the connection drops and reconnects, the browser sends the Last-Event-ID header, and your server can replay only the missed events. Without event IDs, every reconnection loses an unknown number of events.

SSE shines on HTTP/2. The traditional limitation of SSE — the 6 concurrent connection limit per domain on HTTP/1.1 — is eliminated by HTTP/2's stream multiplexing. On HTTP/2, SSE connections share a single TCP connection, making it practical to open multiple event streams to different endpoints without hitting browser limits.

Anti-Patterns

  • Opening multiple EventSource connections on HTTP/1.1 — each SSE connection counts toward the browser's 6-connection-per-domain limit, starving other requests; use HTTP/2 or multiplex event types over a single connection with named events.

  • Not disabling response buffering — reverse proxies like Nginx buffer responses by default, causing events to arrive in batches instead of streaming; set X-Accel-Buffering: no and disable compression for the event stream.

  • Not cleaning up on client disconnect — failing to remove event listeners or clear intervals when req.on('close') fires causes memory leaks and errors from writing to closed response streams.

  • Using SSE for bidirectional communication — SSE is server-to-client only; attempting to send data back through the same connection is not possible; use SSE for the downstream and regular HTTP requests for the upstream.

  • Not including event IDs for resumption — omitting the id: field means every reconnection starts from scratch with no way to replay missed events, creating data gaps the user may never notice.

Install this skill directly: skilldb add websocket-skills

Get CLI access →