Most teams building real-time systems jump straight to WebSockets, assuming complexity buys them power. But I've shipped dozens of production features using Server-Sent Events that outperformed WebSocket implementations in maintainability, debugging, and operational simplicity. Server-Sent Events are underrated—they handle 80% of real-time use cases with a fraction of the overhead.
The misconception and why it matters
When I started building real-time order tracking at my previous company, the team assumed WebSockets were mandatory. We spent two weeks wrestling with connection pooling, fallback logic, and debugging mysterious disconnects across browsers. Then I rebuilt one feature with Server-Sent Events, launched it in three days, and it ran for months without a single operational incident.
The pattern repeats: developers treat Server-Sent Events as a lightweight toy for simple notifications, reserving "serious" real-time for WebSockets. In reality, SSE excels at the majority of production scenarios—notifications, feed updates, progress tracking, live dashboards. It's not that WebSockets are wrong; it's that SSE solves the problem first, and you rarely need to upgrade.
How it works—a long-lived HTTP response streaming text events
Server-Sent Events leverage a simple mechanism: the browser opens a persistent HTTP connection, and the server keeps it open, sending text-based events whenever data changes. No framing layer, no binary protocol, no separate TCP handshake. Just HTTP doing what it was designed to do.
The server sends responses using the text/event-stream MIME type. Each event follows a plain-text format:
// Server (Node.js/Express example)
app.get('/api/orders/stream', (req, res) => {
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
// Send a comment to keep connection alive
res.write(': heartbeat\n\n');
// Send an event with a custom event type
res.write('event: order.updated\n');
res.write('data: {"id": 123, "status": "shipped"}\n');
res.write('id: 42\n\n'); // Event ID for reconnection
// Send another event
res.write('event: order.status\n');
res.write('data: {"orderId": 123, "status": "delivered"}\n');
res.write('id: 43\n\n');
});The client subscribes using the EventSource API, available natively in every modern browser:
// Client (browser)
const es = new EventSource('/api/orders/stream');
es.addEventListener('order.updated', (event) => {
const order = JSON.parse(event.data);
console.log('Order updated:', order);
updateOrderUI(order);
});
es.addEventListener('error', () => {
console.error('Connection lost, reconnecting...');
// Browser handles reconnection automatically
});The format is straightforward: event: names the event type, data: carries the payload (usually JSON), id: sets an identifier for resumption, and blank lines delimit events. The browser parses this automatically and fires the appropriate event listeners.
Built-in reconnection with Last-Event-ID recovery
This is where Server-Sent Events shine operationally. If the connection drops—network hiccup, server restart, browser tab loses focus—the browser automatically reconnects. But here's the clever bit: it sends the last event ID it received, and your server can resume from there.
Let me show a real example from a ride-hailing dashboard I built. Drivers need live trip updates, and we can't afford to miss events if the connection flickers.
// Server tracks event IDs and stores recent events
let eventCounter = 0;
const recentEvents = new Map(); // { id: { type, data } }
app.get('/api/trips/stream', (req, res) => {
const lastEventId = parseInt(req.headers['last-event-id'] || '0', 10);
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
// Replay missed events
for (let id = lastEventId + 1; id <= eventCounter; id++) {
const event = recentEvents.get(id);
if (event) {
res.write(`event: ${event.type}\n`);
res.write(`data: ${JSON.stringify(event.data)}\n`);
res.write(`id: ${id}\n\n`);
}
}
// Subscribe to new events
const onTripUpdate = (trip) => {
eventCounter++;
const id = eventCounter;
const eventData = { type: 'trip.updated', data: trip };
recentEvents.set(id, eventData);
res.write(`event: trip.updated\n`);
res.write(`data: ${JSON.stringify(trip)}\n`);
res.write(`id: ${id}\n\n`);
};
tripEmitter.on('update', onTripUpdate);
res.on('close', () => {
tripEmitter.off('update', onTripUpdate);
});
});The browser's EventSource API handles the reconnection and header injection automatically. When it reconnects, it sends Last-Event-ID: 42, and your server replays events 43 onward. Users never lose updates, and you don't need custom reconnection logic.
Works seamlessly with HTTP infrastructure
One massive operational advantage: Server-Sent Events traverse your entire existing HTTP stack without friction. Proxies, load balancers, CDNs, and gateways understand them because they're just HTTP.
In a WebSocket setup, you often need to configure sticky sessions to route reconnections to the same backend server, or implement message broadcasting across your cluster. With SSE, you can use standard HTTP load balancing. Your reverse proxy doesn't need WebSocket-specific rules. Authentication headers work naturally—you're just attaching cookies or Authorization headers to a regular HTTP request.
Here's an example with NestJS and auth guards:
// Server (NestJS)
import { Controller, Get, Req, Sse } from '@nestjs/common';
import { JwtAuthGuard } from './auth.guard';
@Controller('orders')
export class OrderController {
@Get('stream')
@UseGuards(JwtAuthGuard)
@Sse()
streamOrders(@Req() req: Request) {
return new Observable((observer) => {
const userId = req.user.id;
const emitOrderUpdate = (order) => {
if (order.userId === userId) {
observer.next({
data: order,
id: order.id.toString(),
});
}
};
orderEmitter.on('update', emitOrderUpdate);
return () => orderEmitter.off('update', emitOrderUpdate);
});
}
}The JwtAuthGuard validates the token on the initial HTTP request, and the connection inherits that authenticated context. No separate WebSocket handshake negotiation. Your API gateway, reverse proxy, and load balancer all treat it as regular HTTP. HTTP/2 multiplexing even allows multiple concurrent streams over a single TCP connection, reducing overhead compared to WebSocket's separate connection.
One-directional: server to client only
This is a feature, not a limitation. Server-Sent Events stream from server to client. When the client needs to send something—a filter change, a command, acknowledgment—it uses a regular HTTP request (POST, PUT, fetch).
This separation of concerns keeps your architecture clear. Real-time updates flow down SSE; client commands flow up HTTP requests. No bidirectional framing logic, no message ordering concerns for outbound traffic.
Example: a trading dashboard where price updates stream to the client via SSE, but buy/sell orders go through regular API calls:
// Client
const es = new EventSource('/api/prices/stream');
es.addEventListener('price.updated', (event) => {
const price = JSON.parse(event.data);
updateChart(price);
});
// When user clicks "Buy"
async function buyStock(symbol, quantity) {
const response = await fetch('/api/orders', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ symbol, quantity }),
});
const order = await response.json();
console.log('Order placed:', order);
}Separating streams and commands means each path optimizes for its job. Streams prioritize latency and throughput; commands prioritize reliability and request/response semantics. You're not forcing bidirectional semantics on a problem that benefits from unidirectionality.
Common mistakes and how to avoid them
Mistake 1: Buffering entire event history indefinitely. I once shipped an SSE endpoint that kept every event ever sent in memory. After 48 hours, it consumed 2GB and crashed. Solution: keep a circular buffer of the last N events (I use 1,000–10,000 depending on payload size), indexed by event ID. Older events are discarded.
Mistake 2: Forgetting keepalive pings. Some proxies close idle HTTP connections after 30–60 seconds. If your server never sends data, the client disconnects silently. Send a colon comment every 15 seconds to keep the connection alive: res.write(': keepalive\n\n').
Mistake 3: Not handling client disconnections. In Node.js, when the client closes the browser tab or navigates away, the res object doesn't immediately error. You must listen to the 'close' event and clean up subscriptions, or you'll leak event listeners and memory.
Mistake 4: Assuming all browsers support EventSource. They do in modern browsers, but older IE doesn't (though it's 2024, so less relevant). Polyfills exist, and for backend-to-backend streaming, you can use the Fetch API's getReader() on a streaming response instead.
Mistake 5: Mixing Event IDs across event types. If you emit order.updated with id 42, then inventory.changed with id 43, and the client reconnects, it will replay inventory.changed even if it only cares about orders. Use a single monotonic event ID, and let clients filter by event type on arrival.
Key takeaways
- Server-Sent Events provide persistent, one-directional HTTP streaming with minimal overhead—perfect for dashboards, notifications, and feed updates.
- Built-in reconnection with Last-Event-ID header lets clients resume from the last event without custom logic or message replay complexity.
- SSE integrates seamlessly with HTTP infrastructure: proxies, load balancers, auth headers, and HTTP/2 multiplexing all work without special configuration.
- Keep event history in a bounded circular buffer, send keepalive pings, and clean up subscriptions on client disconnect to avoid memory leaks.
- Use SSE for server-to-client updates, regular HTTP requests for client-to-server commands. This separation keeps architecture clear and each path optimized.
- For 80% of real-time requirements, SSE launches faster, operates more reliably, and debugs more easily than WebSockets. Reserve WebSockets for truly bidirectional, low-latency, high-message-volume scenarios.






Comments
Loading comments…