Server-Sent Events (SSE) — Real-Time Streaming Guide 2026
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
| Feature | SSE | WebSockets | Long Polling |
|---|---|---|---|
| Direction | Server to client only | Bidirectional | Server to client |
| Protocol | HTTP/1.1 or HTTP/2 | WS / WSS | HTTP |
| Auto-reconnect | Built-in | Manual | Manual |
| Browser support | All modern browsers | All modern browsers | Universal |
| Multiplexing (HTTP/2) | Yes | No | No |
| Proxy/firewall friendly | Usually yes | Sometimes blocked | Yes |
| Client library needed | No (EventSource API) | Yes | No |
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: noto 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
EventSourceAPI handles auto-reconnection natively using theLast-Event-IDheader. - 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: nowhen 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