DEV Community

Young Gao
Young Gao

Posted on

Server-Sent Events: The Underrated Alternative to WebSockets for Real-Time Notifications

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
// tsconfig.json
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "strict": true,
    "esModuleInterop": true,
    "outDir": "dist"
  }
}
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

The key features:

  • Event buffering — when a client reconnects, Last-Event-ID header 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(),
  });
}
Enter fullscreen mode Exit fullscreen mode

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}`));
Enter fullscreen mode Exit fullscreen mode

Step 4: Testing It

npx tsx src/server.ts
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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);
  }
});
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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);
});
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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);
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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();
});
Enter fullscreen mode Exit fullscreen mode

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)