Intermediate
25 min
Full Guide

Server-Sent Events (SSE)

Implement one-way real-time communication from server to client

What are Server-Sent Events?

Server-Sent Events (SSE) is a standard for pushing real-time updates from server to client over HTTP. Unlike WebSocket's bidirectional communication, SSE is one-way: server → client only.

SSE is perfect for scenarios where the server needs to push updates but the client rarely or never needs to send data back—think live scores, news feeds, stock tickers, or notification systems.

📊 SSE vs WebSocket

Feature SSE WebSocket
Direction Server → Client only Bidirectional
Protocol HTTP (works over HTTP/2) WebSocket (ws://)
Data format Text only (UTF-8) Text + Binary
Reconnection Built-in automatic Manual implementation
Browser support All modern (not IE) All modern
Complexity Very simple More complex

The EventSource API

// Client-side: Using EventSource

// Create connection to SSE endpoint
const eventSource = new EventSource('/api/events');

// Listen for generic messages (no event type)
eventSource.onmessage = (event) => {
  console.log('Received:', event.data);
  const data = JSON.parse(event.data);
  updateUI(data);
};

// Listen for specific event types
eventSource.addEventListener('notification', (event) => {
  const notification = JSON.parse(event.data);
  showNotification(notification);
});

eventSource.addEventListener('price-update', (event) => {
  const update = JSON.parse(event.data);
  updateStockPrice(update.symbol, update.price);
});

// Handle connection opened
eventSource.onopen = (event) => {
  console.log('SSE connection established');
};

// Handle errors
eventSource.onerror = (event) => {
  if (eventSource.readyState === EventSource.CLOSED) {
    console.log('Connection was closed');
  } else if (eventSource.readyState === EventSource.CONNECTING) {
    console.log('Reconnecting...');
  }
};

// Check connection state
console.log(eventSource.readyState);
// 0 = CONNECTING
// 1 = OPEN
// 2 = CLOSED

// Close connection when done
eventSource.close();

SSE Server Implementation (Node.js)

const express = require('express');
const app = express();

// SSE endpoint
app.get('/api/events', (req, res) => {
  // Set headers for SSE
  res.setHeader('Content-Type', 'text/event-stream');
  res.setHeader('Cache-Control', 'no-cache');
  res.setHeader('Connection', 'keep-alive');
  
  // Disable buffering (important for nginx)
  res.setHeader('X-Accel-Buffering', 'no');
  
  // Send initial connection event
  res.write('data: {"connected": true}\n\n');
  
  // Send updates periodically
  const intervalId = setInterval(() => {
    const data = {
      timestamp: new Date().toISOString(),
      value: Math.random() * 100
    };
    
    // SSE format: "data: \n\n"
    res.write(`data: ${JSON.stringify(data)}\n\n`);
  }, 1000);
  
  // Clean up on client disconnect
  req.on('close', () => {
    console.log('Client disconnected');
    clearInterval(intervalId);
    res.end();
  });
});

app.listen(3000, () => {
  console.log('SSE server running on http://localhost:3000');
});

SSE Message Format

// SSE messages follow a specific text format

// Basic message (triggers 'message' event)
data: Hello, World!

// Multi-line data (joined with newlines)
data: First line
data: Second line
data: Third line

// Named events (triggers specific event listeners)
event: notification
data: {"title": "New message", "body": "You have mail"}

event: price-update  
data: {"symbol": "AAPL", "price": 150.25}

// Message ID (for reconnection tracking)
id: 12345
data: Message with ID

// Retry interval (milliseconds for reconnection)
retry: 5000
data: Reconnect after 5 seconds if disconnected

// Comments (ignored, but keep connection alive)
: this is a comment

// Complete example:
id: msg-001
event: user-joined
retry: 3000
data: {"userId": "123", "username": "john"}

// Note: Each message MUST end with double newline (\n\n)

Complete SSE Server with Event Types

const express = require('express');
const app = express();

// Store connected clients
const clients = new Set();

// SSE connection endpoint
app.get('/api/events', (req, res) => {
  // SSE headers
  res.setHeader('Content-Type', 'text/event-stream');
  res.setHeader('Cache-Control', 'no-cache');
  res.setHeader('Connection', 'keep-alive');
  res.setHeader('X-Accel-Buffering', 'no');
  
  // Add client to set
  clients.add(res);
  console.log(`Client connected. Total: ${clients.size}`);
  
  // Send retry interval
  res.write('retry: 3000\n\n');
  
  // Send welcome event
  sendEvent(res, 'connected', { 
    clientCount: clients.size,
    serverTime: Date.now()
  });
  
  // Cleanup on disconnect
  req.on('close', () => {
    clients.delete(res);
    console.log(`Client disconnected. Total: ${clients.size}`);
  });
});

// Helper to send SSE events
function sendEvent(res, eventType, data, id = null) {
  if (id) {
    res.write(`id: ${id}\n`);
  }
  res.write(`event: ${eventType}\n`);
  res.write(`data: ${JSON.stringify(data)}\n\n`);
}

// Broadcast to all clients
function broadcast(eventType, data) {
  const id = Date.now().toString();
  clients.forEach(client => {
    sendEvent(client, eventType, data, id);
  });
}

// Example: Broadcast notifications
app.post('/api/notify', express.json(), (req, res) => {
  const { title, message } = req.body;
  
  broadcast('notification', {
    title,
    message,
    timestamp: new Date().toISOString()
  });
  
  res.json({ sent: true, recipients: clients.size });
});

// Example: Simulate stock price updates
setInterval(() => {
  const stocks = ['AAPL', 'GOOGL', 'MSFT', 'AMZN'];
  const symbol = stocks[Math.floor(Math.random() * stocks.length)];
  
  broadcast('stock-update', {
    symbol,
    price: (100 + Math.random() * 100).toFixed(2),
    change: (Math.random() * 10 - 5).toFixed(2)
  });
}, 2000);

app.listen(3000);

Handling Reconnection with Last-Event-ID

// Server: Track messages and support resume
const messageHistory = [];
const MAX_HISTORY = 100;

app.get('/api/events', (req, res) => {
  res.setHeader('Content-Type', 'text/event-stream');
  res.setHeader('Cache-Control', 'no-cache');
  res.setHeader('Connection', 'keep-alive');
  
  // Check if client is reconnecting
  const lastEventId = req.headers['last-event-id'];
  
  if (lastEventId) {
    console.log('Client reconnecting from:', lastEventId);
    
    // Send missed messages
    const missedMessages = messageHistory.filter(
      msg => msg.id > parseInt(lastEventId)
    );
    
    missedMessages.forEach(msg => {
      res.write(`id: ${msg.id}\n`);
      res.write(`event: ${msg.event}\n`);
      res.write(`data: ${JSON.stringify(msg.data)}\n\n`);
    });
  }
  
  // ... continue with normal event stream
});

// Store messages with IDs
function broadcastWithHistory(eventType, data) {
  const id = Date.now();
  
  // Store in history
  messageHistory.push({ id, event: eventType, data });
  if (messageHistory.length > MAX_HISTORY) {
    messageHistory.shift();
  }
  
  // Broadcast to clients
  clients.forEach(client => {
    sendEvent(client, eventType, data, id);
  });
}

// Client: EventSource automatically sends Last-Event-ID on reconnect!
const eventSource = new EventSource('/api/events');

// The browser handles this automatically:
// 1. Connection drops
// 2. Browser waits (retry interval)
// 3. Browser reconnects with Last-Event-ID header
// 4. Server can send missed messages

SSE with Authentication

// Problem: EventSource doesn't support custom headers!

// Solution 1: Query parameter (not ideal, token in URL)
const eventSource = new EventSource(
  `/api/events?token=${authToken}`
);

// Solution 2: Cookie-based auth (recommended)
// Set HttpOnly cookie on login, SSE endpoint reads it
app.get('/api/events', authMiddleware, (req, res) => {
  // req.user is set by auth middleware reading cookie
  const userId = req.user.id;
  // ... SSE logic
});

// Solution 3: Use fetch + ReadableStream (modern approach)
async function connectSSE(url, token) {
  const response = await fetch(url, {
    headers: {
      'Authorization': `Bearer ${token}`,
      'Accept': 'text/event-stream'
    }
  });
  
  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
    parseSSEMessages(text).forEach(handleMessage);
  }
}

function parseSSEMessages(text) {
  const messages = [];
  const lines = text.split('\n');
  let currentMessage = {};
  
  for (const line of lines) {
    if (line.startsWith('data: ')) {
      currentMessage.data = line.slice(6);
    } else if (line.startsWith('event: ')) {
      currentMessage.event = line.slice(7);
    } else if (line.startsWith('id: ')) {
      currentMessage.id = line.slice(4);
    } else if (line === '') {
      if (currentMessage.data) {
        messages.push(currentMessage);
        currentMessage = {};
      }
    }
  }
  
  return messages;
}

Real-World Example: Live Feed

// Complete live news/social feed with SSE

// Server
const express = require('express');
const app = express();

const subscribers = new Map(); // userId -> response

app.get('/api/feed', authenticate, (req, res) => {
  const userId = req.user.id;
  
  res.setHeader('Content-Type', 'text/event-stream');
  res.setHeader('Cache-Control', 'no-cache');
  res.setHeader('Connection', 'keep-alive');
  
  subscribers.set(userId, res);
  
  // Send user's personalized feed
  sendEvent(res, 'feed-init', {
    userId,
    timestamp: Date.now()
  });
  
  req.on('close', () => {
    subscribers.delete(userId);
  });
});

// When new content is posted
async function onNewPost(post) {
  // Find followers who should see this
  const followers = await getFollowers(post.authorId);
  
  followers.forEach(followerId => {
    const client = subscribers.get(followerId);
    if (client) {
      sendEvent(client, 'new-post', {
        id: post.id,
        author: post.author,
        content: post.content,
        timestamp: post.createdAt
      });
    }
  });
}

// Client component (React example)
function LiveFeed() {
  const [posts, setPosts] = useState([]);
  
  useEffect(() => {
    const eventSource = new EventSource('/api/feed', {
      withCredentials: true // Send cookies
    });
    
    eventSource.addEventListener('new-post', (event) => {
      const post = JSON.parse(event.data);
      setPosts(prev => [post, ...prev]);
    });
    
    eventSource.addEventListener('post-updated', (event) => {
      const update = JSON.parse(event.data);
      setPosts(prev => prev.map(p => 
        p.id === update.id ? { ...p, ...update } : p
      ));
    });
    
    eventSource.addEventListener('post-deleted', (event) => {
      const { id } = JSON.parse(event.data);
      setPosts(prev => prev.filter(p => p.id !== id));
    });
    
    return () => eventSource.close();
  }, []);
  
  return (
    
{posts.map(post => )}
); }

⚠️ SSE Limitations

  • One-way only - Use POST requests for client → server
  • Text only - No binary data (use base64 if needed)
  • Connection limits - Browsers limit ~6 HTTP connections per domain
  • No custom headers - EventSource API doesn't support them
  • HTTP/1.1 - Each SSE uses one connection; HTTP/2 multiplexes better

💡 When to Use SSE

  • News feeds - One-way updates from server
  • Notifications - Push alerts to users
  • Live scores - Sports, elections, real-time data
  • Stock tickers - Continuous price updates
  • Progress updates - Long-running job status
  • Simple real-time - When WebSocket is overkill