Server-Sent Events: The Underrated Alternative to WebSockets for Real-Time Notifications
WebSockets are the default answer for real-time features. But for most notification systems — activity feeds, live dashboards, deployment status updates — they're overkill. Server-Sent Events (SSE) give you real-time push over plain HTTP with zero client-side libraries, automatic reconnection, and drastically simpler server code.
Here's how to build a production notification system using SSE with Node.js and TypeScript.
Why SSE Over WebSockets
| Feature | SSE | WebSocket |
|---|---|---|
| Direction | Server → Client | Bidirectional |
| Protocol | HTTP | WS (separate protocol) |
| Reconnection | Built-in | Manual implementation |
| Auth | Standard HTTP headers/cookies | Custom handshake |
| Load balancers | Works everywhere | Needs sticky sessions or upgrade support |
| Client code | new EventSource(url) |
new WebSocket(url) + message framing |
| Browser support | All modern browsers | All modern browsers |
If your client only needs to receive updates (notifications, live feeds, progress bars), SSE is the simpler, more reliable choice.
The Protocol in 30 Seconds
SSE is just an HTTP response with Content-Type: text/event-stream that stays open. The server writes lines in this format:
event: notification
data: {"id": 1, "message": "Deploy succeeded"}
id: 1
event: notification
data: {"id": 2, "message": "New comment on PR #42"}
id: 2
Each message is separated by a blank line. That's it. The browser handles parsing, reconnection, and event dispatching.
Project Setup
mkdir sse-notifications && cd sse-notifications
npm init -y
npm install express cors
npm install -D typescript @types/express @types/cors tsx
// tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"esModuleInterop": true,
"outDir": "dist"
}
}
Step 1: The SSE Connection Manager
// src/sse.ts
import { Response } from 'express';
interface Client {
id: string;
userId: string;
res: Response;
connectedAt: Date;
lastEventId: number;
}
class SSEManager {
private clients = new Map<string, Client>();
private eventCounter = 0;
private eventBuffer: Array<{
id: number;
event: string;
data: string;
userId: string | null; // null = broadcast
timestamp: number;
}> = [];
private readonly BUFFER_SIZE = 1000;
private readonly BUFFER_TTL = 5 * 60 * 1000; // 5 minutes
addClient(userId: string, res: Response, lastEventId?: string): string {
const clientId = `${userId}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
// 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: ${JSON.stringify({ clientId })}\n\n`);
const client: Client = {
id: clientId,
userId,
res,
connectedAt: new Date(),
lastEventId: lastEventId ? parseInt(lastEventId) : 0,
};
this.clients.set(clientId, client);
// Replay missed events if client reconnected
if (client.lastEventId > 0) {
this.replayEvents(client);
}
// Send heartbeat every 30s to keep connection alive
const heartbeat = setInterval(() => {
try {
res.write(': heartbeat\n\n');
} catch {
clearInterval(heartbeat);
}
}, 30000);
// Clean up on disconnect
res.on('close', () => {
clearInterval(heartbeat);
this.clients.delete(clientId);
});
return clientId;
}
// Send event to a specific user (all their connected devices)
sendToUser(userId: string, event: string, data: unknown): void {
const id = ++this.eventCounter;
const payload = JSON.stringify(data);
this.bufferEvent(id, event, payload, userId);
for (const client of this.clients.values()) {
if (client.userId === userId) {
this.writeEvent(client.res, id, event, payload);
}
}
}
// Broadcast to all connected clients
broadcast(event: string, data: unknown): void {
const id = ++this.eventCounter;
const payload = JSON.stringify(data);
this.bufferEvent(id, event, payload, null);
for (const client of this.clients.values()) {
this.writeEvent(client.res, id, event, payload);
}
}
getStats() {
const userCounts = new Map<string, number>();
for (const client of this.clients.values()) {
userCounts.set(client.userId, (userCounts.get(client.userId) || 0) + 1);
}
return {
totalConnections: this.clients.size,
uniqueUsers: userCounts.size,
eventsBuffered: this.eventBuffer.length,
totalEventsSent: this.eventCounter,
};
}
private writeEvent(res: Response, id: number, event: string, data: string): void {
try {
res.write(`id: ${id}\nevent: ${event}\ndata: ${data}\n\n`);
} catch {
// Client disconnected — cleanup happens via the 'close' handler
}
}
private bufferEvent(id: number, event: string, data: string, userId: string | null): void {
this.eventBuffer.push({ id, event, data, userId, timestamp: Date.now() });
// Prune old events
const cutoff = Date.now() - this.BUFFER_TTL;
while (this.eventBuffer.length > this.BUFFER_SIZE ||
(this.eventBuffer.length > 0 && this.eventBuffer[0].timestamp < cutoff)) {
this.eventBuffer.shift();
}
}
private replayEvents(client: Client): void {
const missed = this.eventBuffer.filter(
(e) => e.id > client.lastEventId && (e.userId === null || e.userId === client.userId)
);
for (const event of missed) {
this.writeEvent(client.res, event.id, event.event, event.data);
}
}
}
export const sseManager = new SSEManager();
The key features:
-
Event buffering — when a client reconnects,
Last-Event-IDheader tells us where they left off, and we replay missed events - Heartbeats — keep the connection alive through proxies and load balancers
-
X-Accel-Buffering: no— prevents nginx from buffering the stream (a common gotcha) - Per-user targeting — send notifications to specific users across all their devices
Step 2: Notification Service
// src/notifications.ts
import { sseManager } from './sse.js';
interface Notification {
id: string;
type: 'info' | 'success' | 'warning' | 'error';
title: string;
message: string;
link?: string;
timestamp: string;
}
// In-memory store (use Redis or a database in production)
const notifications = new Map<string, Notification[]>();
export function createNotification(
userId: string,
type: Notification['type'],
title: string,
message: string,
link?: string
): Notification {
const notification: Notification = {
id: crypto.randomUUID(),
type,
title,
message,
link,
timestamp: new Date().toISOString(),
};
// Store
const userNotifs = notifications.get(userId) || [];
userNotifs.unshift(notification);
if (userNotifs.length > 100) userNotifs.pop(); // Keep last 100
notifications.set(userId, userNotifs);
// Push via SSE
sseManager.sendToUser(userId, 'notification', notification);
return notification;
}
export function getNotifications(userId: string, limit = 20): Notification[] {
return (notifications.get(userId) || []).slice(0, limit);
}
export function broadcastAnnouncement(title: string, message: string): void {
sseManager.broadcast('announcement', {
id: crypto.randomUUID(),
type: 'info',
title,
message,
timestamp: new Date().toISOString(),
});
}
Step 3: Express Routes
// src/server.ts
import express from 'express';
import cors from 'cors';
import { sseManager } from './sse.js';
import { createNotification, getNotifications, broadcastAnnouncement } from './notifications.js';
const app = express();
app.use(cors());
app.use(express.json());
// SSE endpoint — client connects here
app.get('/events/:userId', (req, res) => {
const { userId } = req.params;
const lastEventId = req.headers['last-event-id'] as string | undefined;
const clientId = sseManager.addClient(userId, res, lastEventId);
console.log(`Client connected: ${clientId} (user: ${userId})`);
});
// REST: Get notification history
app.get('/api/notifications/:userId', (req, res) => {
const limit = parseInt(req.query.limit as string) || 20;
const notifs = getNotifications(req.params.userId, limit);
res.json({ notifications: notifs, count: notifs.length });
});
// REST: Send notification to user
app.post('/api/notifications/:userId', (req, res) => {
const { type = 'info', title, message, link } = req.body;
if (!title || !message) {
return res.status(400).json({ error: 'title and message are required' });
}
const notification = createNotification(req.params.userId, type, title, message, link);
res.status(201).json(notification);
});
// REST: Broadcast to all
app.post('/api/broadcast', (req, res) => {
const { title, message } = req.body;
if (!title || !message) {
return res.status(400).json({ error: 'title and message are required' });
}
broadcastAnnouncement(title, message);
res.json({ sent: true, connections: sseManager.getStats().totalConnections });
});
// Stats
app.get('/api/stats', (_, res) => {
res.json(sseManager.getStats());
});
// Demo page
app.get('/', (_, res) => {
res.send(`<!DOCTYPE html>
<html>
<head><title>SSE Notifications Demo</title></head>
<body>
<h1>Notifications</h1>
<div id="notifs"></div>
<script>
const userId = 'demo-user';
const events = new EventSource('/events/' + userId);
events.addEventListener('connected', (e) => {
console.log('Connected:', JSON.parse(e.data));
});
events.addEventListener('notification', (e) => {
const n = JSON.parse(e.data);
const div = document.createElement('div');
div.style.cssText = 'padding:12px;margin:8px 0;border-radius:8px;background:#f0f4ff;border-left:4px solid #4f46e5';
div.innerHTML = '<strong>' + n.title + '</strong><br>' + n.message + '<br><small>' + n.timestamp + '</small>';
document.getElementById('notifs').prepend(div);
});
events.addEventListener('announcement', (e) => {
const n = JSON.parse(e.data);
const div = document.createElement('div');
div.style.cssText = 'padding:12px;margin:8px 0;border-radius:8px;background:#fef3c7;border-left:4px solid #f59e0b';
div.innerHTML = '📢 <strong>' + n.title + '</strong><br>' + n.message;
document.getElementById('notifs').prepend(div);
});
events.onerror = () => console.log('Reconnecting...');
</script>
</body>
</html>`);
});
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => console.log(`Server running on http://localhost:${PORT}`));
Step 4: Testing It
npx tsx src/server.ts
In another terminal:
# Send a notification
curl -X POST http://localhost:3000/api/notifications/demo-user \
-H "Content-Type: application/json" \
-d '{"type": "success", "title": "Deploy Complete", "message": "v2.3.1 deployed to production"}'
# Broadcast to all
curl -X POST http://localhost:3000/api/broadcast \
-H "Content-Type: application/json" \
-d '{"title": "Scheduled Maintenance", "message": "Database maintenance at 2am UTC"}'
# Check stats
curl http://localhost:3000/api/stats
Open http://localhost:3000 in your browser — notifications appear instantly without polling.
Production Considerations
Scaling Beyond One Server
SSE connections are stateful — each lives on a specific server. For multiple servers, use Redis pub/sub:
// src/sse-redis.ts
import { createClient } from 'redis';
import { sseManager } from './sse.js';
const pub = createClient({ url: process.env.REDIS_URL });
const sub = pub.duplicate();
await pub.connect();
await sub.connect();
// Publish events to Redis (instead of calling sseManager directly)
export async function publishNotification(userId: string, event: string, data: unknown) {
await pub.publish('sse:events', JSON.stringify({ userId, event, data }));
}
// Each server subscribes and delivers to its local clients
await sub.subscribe('sse:events', (message) => {
const { userId, event, data } = JSON.parse(message);
if (userId) {
sseManager.sendToUser(userId, event, data);
} else {
sseManager.broadcast(event, data);
}
});
Connection Limits
Each SSE connection holds an open HTTP connection. Default Node.js limit is ~1000 concurrent sockets. Increase it:
import http from 'http';
const server = http.createServer(app);
server.maxConnections = 10000;
server.listen(PORT);
For higher numbers, use uWebSockets.js or put a reverse proxy (nginx with proxy_buffering off) in front.
Authentication
SSE uses standard HTTP, so auth works normally:
app.get('/events/:userId', authenticate, (req, res) => {
// authenticate middleware validates JWT from cookie or Authorization header
if (req.user.id !== req.params.userId) {
return res.status(403).json({ error: 'Forbidden' });
}
sseManager.addClient(req.user.id, res);
});
The EventSource API doesn't support custom headers, so use cookies or query params for auth:
// Client-side with auth token
const events = new EventSource('/events/me?token=' + authToken);
Or use the fetch-based polyfill for custom headers:
// Using fetch for SSE with headers
const response = await fetch('/events/me', {
headers: { Authorization: `Bearer ${token}` },
});
const reader = response.body.getReader();
const decoder = new TextDecoder();
while (true) {
const { done, value } = await reader.read();
if (done) break;
const text = decoder.decode(value);
// Parse SSE format manually
for (const line of text.split('\n')) {
if (line.startsWith('data: ')) {
const data = JSON.parse(line.slice(6));
handleEvent(data);
}
}
}
Graceful Shutdown
process.on('SIGTERM', () => {
// Close all SSE connections — clients will auto-reconnect to another server
for (const client of sseManager.clients.values()) {
client.res.end();
}
server.close();
});
When to Use WebSockets Instead
SSE is wrong when:
- Clients send frequent messages (chat, collaborative editing, gaming)
- Binary data streaming (SSE is text-only)
- You need sub-10ms latency (SSE adds HTTP overhead)
SSE is right when:
- Server pushes updates to clients (notifications, feeds, dashboards)
- You want simplicity (no library needed, standard HTTP)
- Reliability matters (auto-reconnect with event replay)
Conclusion
SSE gives you real-time server-to-client push with less code, better reliability, and simpler infrastructure than WebSockets. The built-in reconnection with Last-Event-ID means clients never miss events, even across network drops. For notification systems, live dashboards, and status feeds, it's the pragmatic choice.
If this was helpful, you can support my work at ko-fi.com/nopkt
If this article helped you, consider buying me a coffee on Ko-fi! Follow me for more production backend patterns.
Top comments (0)