Skip to content

Server-Sent Events for Real-Time Updates — Complete Implementation Guide

DodaTech Updated 2026-06-22 10 min read

In this tutorial, you'll learn about Server. We cover key concepts, practical examples, and best practices to help you understand and apply this topic effectively.

Server-Sent Events (SSE) is a standard protocol that enables servers to push real-time updates to web clients over a single long-lived HTTP connection, using the text/event-stream content type.

What You'll Learn

By the end of this tutorial, you will implement SSE servers in Python (Flask/FastAPI) and Node.js (Express), handle reconnection with Last-Event-ID, multiplex multiple event channels, and deploy SSE behind a reverse Proxy.

Why It Matters

SSE provides a simpler alternative to WebSockets for unidirectional server-to-client streaming. It works over standard HTTP, automatically reconnects, and integrates with existing HTTP infrastructure like load balancers and CDNs. Doda Browser uses SSE to push real-time download progress and malware scan status to the browser UI.

Real-World Use

A stock trading dashboard uses SSE to push price updates to the UI. The server broadcasts bid/ask prices as events, and the client updates the display in real time. Unlike WebSockets, SSE automatically reconnects if the connection drops without custom reconnection logic.

SSE Protocol Flow

sequenceDiagram
    participant Client
    participant Server

    Client->>Server: GET /events (Accept: text/event-stream)
    Server-->>Client: HTTP 200 (Content-Type: text/event-stream)
    Note over Server: Connection stays open
    Server-->>Client: data: {"message": "Connected"}\n\n
    Server-->>Client: event: update\ndata: {"id": 1}\n\n
    Server-->>Client: event: notification\ndata: {"type": "info"}\n\n
    Client->>Server: (Client disconnects)
    Note over Client: 3 seconds later...
    Client->>Server: GET /events (Last-Event-ID: 42)
    Server-->>Client: HTTP 200 (resume from event 43)

The client opens a connection with EventSource. The server keeps the connection open and sends text/event-stream data. If the connection drops, the client automatically reconnects with Last-Event-ID.

Python: FastAPI SSE

# SSE_server.py
# Server-Sent Events with FastAPI
import asyncio
import JSON
from datetime import datetime
from FastAPI import FastAPI, Request
from FastAPI.responses import StreamingResponse
from typing import AsyncGenerator

app = FastAPI()

class EventManager:
    """Manage SSE connections and broadcast events."""

    def __init__(self):
        self.subscribers = []

    def subscribe(self):
        """Register a new subscriber Queue."""
        Queue = asyncio.Queue()
        self.subscribers.append(Queue)
        return Queue

    def unsubscribe(self, Queue):
        """Remove a subscriber Queue."""
        self.subscribers.remove(Queue)

    async def broadcast(self, event_type: str, data: dict):
        """Send event to all connected clients."""
        message = f"event: {event_type}\ndata: {JSON.dumps(data)}\n\n"
        dead_queues = []
        for Queue in self.subscribers:
            try:
                await Queue.put(message)
            except Exception:
                dead_queues.append(Queue)
        for q in dead_queues:
            self.subscribers.remove(q)

manager = EventManager()

async def event_generator(request: Request) -> AsyncGenerator[str, None]:
    """Generate SSE event stream for a client."""
    Queue = manager.subscribe()
    try:
        # Send initial connection event
        yield f"event: connected\ndata: {JSON.dumps({'status': 'ok'})}\n\n"

        while True:
            # Check if client disconnected
            if await request.is_disconnected():
                break

            try:
                # Wait for broadcast events with 30s timeout
                message = await asyncio.wait_for(Queue.get(), timeout=30.0)
                yield message
            except asyncio.TimeoutError:
                # Send keepalive comment to prevent Proxy timeout
                yield ": keepalive\n\n"
    finally:
        manager.unsubscribe(Queue)

@app.get("/events")
async def SSE_endpoint(request: Request):
    """SSE endpoint that clients connect to."""
    return StreamingResponse(
        event_generator(request),
        media_type="text/event-stream",
        headers={
            "Cache-Control": "no-cache",
            "Connection": "keep-alive",
            "X-Accel-Buffering": "no",
        }
    )

@app.post("/broadcast")
async def broadcast_event(event_type: str, data: dict):
    """API endpoint to broadcast events to all SSE clients."""
    await manager.broadcast(event_type, data)
    return {"status": "broadcast", "subscribers": len(manager.subscribers)}

# Simulated real-time updates
@app.on_event("startup")
async def start_background_tasks():
    """Simulate real-time data updates."""
    async def simulate_updates():
        counter = 0
        while True:
            await asyncio.sleep(5)
            counter += 1
            await manager.broadcast("update", {
                "id": counter,
                "timestamp": datetime.utcnow().isoformat(),
                "value": counter * 10,
            })
    asyncio.create_task(simulate_updates())

Node.js: Express SSE

// SSE-Express.js
// Server-Sent Events with Express
const Express = require('Express');
const app = Express();

const clients = new Map();
let eventId = 0;

function sseMiddleware(req, res, next) {
  res.SSE = {
    send(event, data) {
      res.write(`event: ${event}\n`);
      res.write(`id: ${++eventId}\n`);
      res.write(`data: ${JSON.stringify(data)}\n\n`);
    },
    comment(text) {
      res.write(`: ${text}\n\n`);
    },
  };
  next();
}

app.get('/events', sseMiddleware, (req, res) => {
  res.writeHead(200, {
    'Content-Type': 'text/event-stream',
    'Cache-Control': 'no-cache',
    'Connection': 'keep-alive',
    'X-Accel-Buffering': 'no',
  });

  const clientId = Date.now();
  const client = {
    id: clientId,
    response: res,
    connectedAt: new Date(),
  };

  clients.set(clientId, client);
  console.log(`Client ${clientId} connected (total: ${clients.size})`);

  // Send initial event
  res.SSE.send('connected', { clientId, timestamp: new Date().toISOString() });

  // Send keepalive every 30 seconds
  const keepalive = setInterval(() => {
    res.SSE.comment('keepalive');
  }, 30000);

  req.on('close', () => {
    clearInterval(keepalive);
    clients.delete(clientId);
    console.log(`Client ${clientId} disconnected (total: ${clients.size})`);
  });
});

// Broadcast to all connected clients
app.post('/broadcast', Express.JSON(), (req, res) => {
  const { event, data } = req.body;
  console.log(`Broadcasting '${event}' to ${clients.size} clients`);

  clients.forEach((client) => {
    client.response.SSE.send(event, data);
  });

  res.JSON({ broadcast: true, recipients: clients.size });
});

// Real-time ticker example
setInterval(() => {
  const ticker = {
    symbol: 'DODA',
    price: (100 + Math.random() * 10).toFixed(2),
    timestamp: new Date().toISOString(),
  };
  clients.forEach((client) => {
    client.response.SSE.send('ticker', ticker);
  });
}, 1000);

app.listen(3000);

Client-Side Implementation

// SSE-client.js
// Browser-side SSE consumer with reconnection handling

class SSEConnection {
  constructor(URL, options = {}) {
    this.URL = URL;
    this.options = {
      maxRetries: options.maxRetries || Infinity,
      retryDelay: options.retryDelay || 3000,
      onConnect: options.onConnect || (() => {}),
      onError: options.onError || (() => {}),
      ...options,
    };
    this.retryCount = 0;
    this.connect();
  }

  connect() {
    this.eventSource = new EventSource(this.URL);

    this.eventSource.addEventListener('connected', (event) => {
      const data = JSON.parse(event.data);
      console.log('SSE connected:', data);
      this.retryCount = 0;
      this.options.onConnect(data);
    });

    this.eventSource.addEventListener('update', (event) => {
      const data = JSON.parse(event.data);
      this.handleUpdate(data);
    });

    this.eventSource.addEventListener('notification', (event) => {
      const data = JSON.parse(event.data);
      this.showNotification(data);
    });

    this.eventSource.onerror = (error) => {
      console.error('SSE error:', error);
      this.eventSource.close();

      if (this.retryCount < this.options.maxRetries) {
        this.retryCount++;
        const delay = this.options.retryDelay * Math.pow(2, this.retryCount - 1);
        console.log(`Reconnecting in ${delay}ms (attempt ${this.retryCount})`);
        setTimeout(() => this.connect(), delay);
      } else {
        this.options.onError(new Error('Max SSE retries exceeded'));
      }
    };
  }

  handleUpdate(data) {
    // Override in subclass or pass handler
    console.log('Update received:', data);
  }

  showNotification(data) {
    if ('Notification' in window && Notification.permission === 'granted') {
      new Notification(data.title || 'New Update', {
        body: data.message || JSON.stringify(data),
      });
    }
  }

  close() {
    this.eventSource.close();
  }
}

// Usage
const SSE = new SSEConnection('/events', {
  maxRetries: 5,
  retryDelay: 2000,
  onConnect: (data) => {
    document.getElementById('status').textContent = 'Connected';
  },
  onError: (err) => {
    document.getElementById('status').textContent = 'Disconnected';
  },
});

Table: SSE vs WebSocket vs Polling

Feature SSE WebSocket Polling
Direction Server-to-client Bidirectional Client-to-server
Transport HTTP WebSocket protocol HTTP
Auto-reconnect Built-in Manual N/A
Browser support All modern browsers All modern browsers All browsers
Message ordering Guaranteed Not guaranteed N/A
Binary data No (text only) Yes Yes
Scalability Simple (HTTP) Connection management Simple
Firewall-friendly Yes (HTTP) May be blocked Yes

Common Errors

1. Proxy Buffering Interrupts SSE

NGINX and other reverse proxies buffer responses by default. SSE streams break when buffered. Disable buffering with proxy_buffering off; and set X-Accel-Buffering: no header. Test SSE directly before adding Proxy layers.

2. Not Sending Keepalive Comments

Idle SSE connections may be terminated by proxies, load balancers, or browser timeouts. Send a comment line (: keepalive\n\n) every 30 seconds to keep the connection alive. This prevents the client from reconnecting unnecessarily.

3. Exceeding Browser Connection Limits

Browsers limit concurrent connections to a single domain (typically 6). Each SSE connection counts against this limit. If your page needs multiple event streams, use a single multiplexed endpoint instead of many separate SSE connections.

4. Missing Last-Event-ID Handling

The browser sends Last-Event-ID on reconnection, but the server must handle it. Track event IDs server-side and replay missed events when a client reconnects. Without this, clients lose events that occurred during the disconnection.

5. Blocking the Event Loop

If your SSE endpoint runs synchronous blocking code (disk I/O, heavy computation), all clients wait. Use async/await in Node.js or asyncio in Python. Offload blocking work to background threads or worker processes.

6. Not Closing Connections on Server Shutdown

When the server restarts, open SSE connections remain hanging until timeout. Implement graceful shutdown that closes all SSE connections, causing clients to reconnect to the new server instance.

Practice Questions

1. How does SSE differ from WebSocket in terms of protocol?

SSE uses standard HTTP with text/event-stream content type. WebSocket uses its own protocol (ws://) after an HTTP upgrade handshake. SSE is unidirectional (server to client) while WebSocket is bidirectional.

2. How does automatic reconnection work in SSE?

When the connection drops, the browser's EventSource automatically attempts to reconnect after 3 seconds (default). The server can control this with retry: <milliseconds> in the event stream. The client sends Last-Event-ID to resume from the last received event.

3. What are the limitations of SSE compared to WebSocket?

SSE only supports text data (no binary frames). It has limited browser connection limits per domain. It does not support client-to-server streaming within the same connection. For bidirectional communication, use WebSocket.

4. How do you broadcast events to specific clients only?

Use channels or rooms. Each client subscribes to channels on connection (e.g., ?channel=user:42). The server maintains a map of channels to clients and broadcasts only to matching subscribers. This avoids sending irrelevant data.

5. Challenge: Build a real-time notification system using SSE that: (1) authenticates clients via token in the connection URL (2) supports channel-based subscriptions (user-specific, role-specific, global) (3) includes a broadcast endpoint for server-side events (4) handles reconnection with Last-Event-ID replay (5) sends keepalive comments every 25 seconds (6) works behind an NGINX reverse Proxy with proper buffering configuration.

Mini Project: SSE Dashboard Backend

# SSE_dashboard.py
# Real-time dashboard with SSE updates
import asyncio
import JSON
import random
from datetime import datetime
from FastAPI import FastAPI, Request
from FastAPI.responses import StreamingResponse
from typing import AsyncGenerator

app = FastAPI()

# Simulated metrics
class MetricsGenerator:
    def __init__(self):
        self.metrics = {
            'cpu': 45.0,
            'memory': 62.0,
            'requests_per_sec': 150,
            'active_users': 42,
            'error_rate': 0.5,
        }

    async def update(self):
        """Simulate metric changes."""
        self.metrics['cpu'] = max(0, min(100, self.metrics['cpu'] + random.uniform(-5, 5)))
        self.metrics['memory'] = max(0, min(100, self.metrics['memory'] + random.uniform(-3, 3)))
        self.metrics['requests_per_sec'] = max(0, int(
            self.metrics['requests_per_sec'] + random.uniform(-20, 20)
        ))
        self.metrics['active_users'] = max(0, int(
            self.metrics['active_users'] + random.randint(-3, 3)
        ))
        self.metrics['error_rate'] = max(0, min(100,
            self.metrics['error_rate'] + random.uniform(-0.2, 0.2)
        ))
        self.metrics['timestamp'] = datetime.utcnow().isoformat()
        return self.metrics

metrics = MetricsGenerator()
subscribers = []

async def SSE_stream(request: Request) -> AsyncGenerator[str, None]:
    """SSE stream for dashboard metrics."""
    Queue = asyncio.Queue()
    subscribers.append(Queue)
    try:
        while True:
            if await request.is_disconnected():
                break
            try:
                data = await asyncio.wait_for(Queue.get(), timeout=25.0)
                yield data
            except asyncio.TimeoutError:
                yield ": keepalive\n\n"
    finally:
        subscribers.remove(Queue)

@app.get("/dashboard/stream")
async def dashboard_SSE(request: Request):
    return StreamingResponse(
        SSE_stream(request),
        media_type="text/event-stream",
        headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}
    )

@app.on_event("startup")
async def start_metrics_broadcast():
    async def broadcast_metrics():
        while True:
            await asyncio.sleep(2)
            data = await metrics.update()
            message = f"event: metrics\ndata: {JSON.dumps(data)}\n\n"
            for Queue in subscribers[:]:
                await Queue.put(message)
    asyncio.create_task(broadcast_metrics())

# Run with: uvicorn SSE_dashboard:app --reload

FAQ

When should I use SSE instead of WebSocket? Use SSE when you only need server-to-client updates (notifications, feed updates, status changes). Use WebSocket when you need bidirectional communication (chat, collaborative editing, gaming). SSE is simpler to implement and debug.

How do I handle SSE behind a load balancer? Configure the load balancer for sticky sessions (session affinity) or use a pub/sub system (Redis) to broadcast events across all server instances. Each server instance subscribes to Redis channels and forwards events to its connected clients.

What is the maximum number of concurrent SSE connections? Each browser limits connections to 6 per domain. Server-side, the limit depends on your infrastructure. A Node.js server can handle 10,000+ concurrent SSE connections on modest hardware. Use clustering or worker processes for higher scale.

How do I send authentication errors over SSE? Since the connection is already established, send an error event type with the error details. The client listens for error events and handles them. Alternatively, reject the connection at the HTTP level with a 401 status code before establishing the stream.

Can SSE work with HTTP/2? Yes, SSE works well with HTTP/2. HTTP/2 multiplexing allows multiple SSE streams over a single TCP connection, bypassing the browser's 6-connection limit. The same text/event-stream format works over HTTP/2.

Related Concepts

Webhook Implementation
Graceful Shutdown
Message Queue Patterns

What's Next

You now understand Server-Sent Events for real-time updates. Next, learn about webhook implementation for server-to-server event delivery, then explore message Queue patterns for reliable event propagation.

  • Practice daily — Replace a polling endpoint in your app with an SSE stream
  • Build a project — Build a real-time server monitoring dashboard with SSE that streams CPU, memory, and request metrics
  • Explore related topics — Check out Redis pub/sub for cross-server SSE broadcasting

Built by the developers of Doda Browser, DodaZIP, and Durga Antivirus Pro.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro