Server-Sent Events (SSE) — Real-Time Streaming Guide 2026

Sanjeev SharmaSanjeev Sharma
6 min read

Advertisement

Introduction

Why This Matters

Server-Sent Events (SSE) have had a massive resurgence in 2026 because every LLM API — OpenAI, Anthropic, Gemini — streams tokens over SSE. If you are building an AI chatbot, a live dashboard, or a notification feed, SSE is often the right choice: it uses plain HTTP, supports auto-reconnection natively, and requires zero extra libraries on the client. Understanding SSE properly means fewer dependencies and simpler infrastructure than WebSockets for one-directional streaming.

SSE vs WebSockets vs Long Polling

FeatureSSEWebSocketsLong Polling
DirectionServer to client onlyBidirectionalServer to client
ProtocolHTTP/1.1 or HTTP/2WS / WSSHTTP
Auto-reconnectBuilt-inManualManual
Browser supportAll modern browsersAll modern browsersUniversal
Multiplexing (HTTP/2)YesNoNo
Proxy/firewall friendlyUsually yesSometimes blockedYes
Client library neededNo (EventSource API)YesNo

Choose SSE when data flows only from server to client. Choose WebSockets when you need bidirectional communication.

Basic SSE Server with Express

// src/sse-server.ts
import express, { Request, Response } from 'express';
 
const app = express();
 
// Track connected clients
interface SSEClient {
  id: string;
  res: Response;
  userId: string;
}
 
const clients = new Map<string, SSEClient>();
 
app.get('/events', (req: Request, res: Response) => {
  const userId = req.query.userId as string;
 
  if (!userId) {
    return res.status(401).json({ error: 'userId required' });
  }
 
  // Required SSE headers
  res.setHeader('Content-Type', 'text/event-stream');
  res.setHeader('Cache-Control', 'no-cache, no-transform');
  res.setHeader('Connection', 'keep-alive');
  res.setHeader('X-Accel-Buffering', 'no'); // Disable nginx buffering
 
  res.flushHeaders(); // Send headers immediately
 
  const clientId = crypto.randomUUID();
  clients.set(clientId, { id: clientId, res, userId });
 
  // Send initial event to confirm connection
  sendEvent(res, 'connected', { clientId });
 
  // Clean up when client disconnects
  req.on('close', () => {
    clients.delete(clientId);
    console.log(`Client ${clientId} disconnected (${clients.size} remaining)`);
  });
});
 
// SSE event format: "event: type\ndata: payload\n\n"
function sendEvent(res: Response, 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`);
}
 
// Broadcast to all clients or filter by userId
function broadcast(event: string, data: unknown, targetUserId?: string) {
  for (const client of clients.values()) {
    if (!targetUserId || client.userId === targetUserId) {
      sendEvent(client.res, event, data);
    }
  }
}
 
app.listen(3000, () => console.log('SSE server on port 3000'));

LLM Token Streaming with SSE

The most common SSE pattern in 2026 is streaming AI-generated text token by token, exactly like ChatGPT does.

import OpenAI from 'openai';
 
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
 
app.post('/chat', async (req: Request, res: Response) => {
  const { prompt } = req.body as { prompt: string };
 
  // SSE headers
  res.setHeader('Content-Type', 'text/event-stream');
  res.setHeader('Cache-Control', 'no-cache');
  res.setHeader('Connection', 'keep-alive');
  res.flushHeaders();
 
  try {
    const stream = openai.beta.chat.completions.stream({
      model: 'gpt-4o',
      messages: [{ role: 'user', content: prompt }],
    });
 
    for await (const chunk of stream) {
      const token = chunk.choices[0]?.delta?.content ?? '';
 
      if (token) {
        sendEvent(res, 'token', { token });
      }
    }
 
    sendEvent(res, 'done', { message: 'Stream complete' });
    res.end();
  } catch (err) {
    sendEvent(res, 'error', { message: 'Stream failed' });
    res.end();
  }
});

Client-Side EventSource API

The browser EventSource API handles SSE natively — no library needed.

// Browser client (TypeScript)
const eventSource = new EventSource('/events?userId=user-123');
 
// Listen for named events
eventSource.addEventListener('connected', (event) => {
  const data = JSON.parse(event.data);
  console.log('Connected:', data.clientId);
});
 
eventSource.addEventListener('notification', (event) => {
  const notification = JSON.parse(event.data);
  showToast(notification.message);
});
 
eventSource.addEventListener('token', (event) => {
  const { token } = JSON.parse(event.data);
  appendToChat(token);
});
 
// Handle connection errors — EventSource retries automatically
eventSource.onerror = (err) => {
  console.error('SSE error, will retry:', err);
};
 
// Explicit close when done
function cleanup() {
  eventSource.close();
}

Authenticated SSE with JWT

SSE uses GET requests, so you cannot set an Authorization header directly. Use query params (for short-lived tokens) or cookies.

// Middleware to authenticate SSE connections
import jwt from 'jsonwebtoken';
 
app.get('/events', async (req: Request, res: Response) => {
  // Option 1: Short-lived token in query param
  const token = req.query.token as string;
 
  // Option 2: HTTP-only cookie (preferred)
  // const token = req.cookies.sessionToken;
 
  if (!token) {
    return res.status(401).end();
  }
 
  try {
    const payload = jwt.verify(token, process.env.JWT_SECRET!) as { sub: string };
    const userId = payload.sub;
 
    res.setHeader('Content-Type', 'text/event-stream');
    res.setHeader('Cache-Control', 'no-cache');
    res.setHeader('Connection', 'keep-alive');
    res.flushHeaders();
 
    const clientId = crypto.randomUUID();
    clients.set(clientId, { id: clientId, res, userId });
 
    req.on('close', () => clients.delete(clientId));
  } catch {
    res.status(401).end();
  }
});

Reconnection and Event IDs

SSE has built-in reconnection. The browser automatically reconnects and sends the Last-Event-ID header so you can resume from where it left off.

let eventId = 0;
 
function sendEventWithId(res: Response, event: string, data: unknown) {
  eventId++;
  res.write(`id: ${eventId}\n`);
  res.write(`event: ${event}\n`);
  res.write(`data: ${JSON.stringify(data)}\n\n`);
}
 
// On reconnection, send missed events
app.get('/events', (req: Request, res: Response) => {
  const lastEventId = parseInt(req.headers['last-event-id'] as string ?? '0', 10);
 
  res.setHeader('Content-Type', 'text/event-stream');
  res.setHeader('Cache-Control', 'no-cache');
  res.setHeader('Retry', '3000'); // Tell client to retry after 3s
  res.flushHeaders();
 
  // Replay missed events from a store
  const missed = eventStore.getAfter(lastEventId);
  for (const event of missed) {
    sendEventWithId(res, event.type, event.data);
  }
});

Common Mistakes

Mistake 1 — Forgetting res.flushHeaders(): without this, Express buffers the response and the client receives nothing until the connection closes.

Mistake 2 — No nginx buffering config: nginx buffers SSE responses by default. Set X-Accel-Buffering: no or configure proxy_buffering off to fix this.

Mistake 3 — Leaking connections: always listen to req.on('close') and remove the client from your map — otherwise memory grows without bound.

Mistake 4 — Using SSE when you need bidirectional communication: SSE is server-to-client only. If the client needs to push events back, combine SSE with a regular POST endpoint, or switch to WebSockets.

Best Practices

  • Set X-Accel-Buffering: no to prevent nginx from buffering SSE responses.
  • Use event IDs and replay missed events on reconnection for reliable delivery.
  • Implement a heartbeat comment every 15-30 seconds (res.write(': heartbeat\n\n')) to keep connections alive through proxies.
  • Limit maximum connections per user to prevent resource exhaustion.
  • Use Redis pub/sub to fan out SSE events across multiple Node.js instances.

Key Takeaways

  • SSE is a native HTTP feature for server-to-client streaming — no WebSocket handshake or library required.
  • Every major LLM API streams tokens over SSE, making it an essential pattern for AI applications in 2026.
  • The browser EventSource API handles auto-reconnection natively using the Last-Event-ID header.
  • SSE works over HTTP/2, which allows multiplexing multiple SSE streams over a single TCP connection.
  • res.flushHeaders() must be called immediately after setting SSE headers or clients will receive nothing.
  • Set X-Accel-Buffering: no when serving SSE through nginx to prevent response buffering.
  • Use req.on('close') to clean up disconnected clients and prevent memory leaks.
  • SSE is simpler than WebSockets for one-directional streaming; use WebSockets only when bidirectional communication is required.

Advertisement

Sanjeev Sharma

Written by

Sanjeev Sharma

Full Stack Engineer · E-mopro

Related reading