← Back to DevBytes

Implementing Server-Sent Events: From Theory to Practice

Introduction to Server-Sent Events

Server-Sent Events (SSE) is a standard web technology that allows servers to push real-time updates to clients over a single, long-lived HTTP connection. Unlike the traditional request–response cycle where the client always initiates communication, SSE flips the model: the server retains an open channel and streams data as soon as it becomes available. The client simply opens a connection and listens, receiving messages as they arrive.

SSE is built entirely on standard HTTP and uses the text/event-stream MIME type. It’s natively supported in all modern browsers through the EventSource API, making it incredibly easy to implement without any third-party libraries. Whether you’re delivering live stock tickers, news feeds, server metrics, or social media updates, SSE offers a lightweight, reliable, and auto-reconnecting solution for unidirectional data flow from server to client.

Why SSE Matters

🚀 Deploy your AI agent in 10 minutes

Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.

Try it free →

Before SSE, developers typically resorted to polling or heavyweight WebSocket connections for real-time data. Each approach has its drawbacks:

SSE shines when you only need the server to push updates to clients. It’s simpler than WebSockets, works transparently with HTTP/1.1 and HTTP/2, benefits from existing HTTP infrastructure (load balancers, CDNs, authentication), and includes built-in browser features like automatic reconnection and event ID tracking. If your use case is live dashboards, status changes, log streaming, or notification feeds, SSE is often the ideal choice.

How Server-Sent Events Work

The protocol is straightforward. The client initiates an HTTP GET request with Accept: text/event-stream. The server responds with headers:

The connection remains open, and the server writes a stream of UTF-8 encoded event messages. Each event is a block of text fields separated by newlines. The fields include:

Comments (lines starting with a colon) are used as keep-alive pings to prevent the connection from being closed by proxies or timeouts. The client-side EventSource interface handles parsing, events, and reconnection automatically.

Building a Node.js SSE Server

Let’s start with a minimal Express server. The key is to set the proper headers and then write events in a loop, keeping the response open indefinitely.

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

app.get('/events', (req, res) => {
  // SSE headers
  res.writeHead(200, {
    'Content-Type': 'text/event-stream',
    'Cache-Control': 'no-cache',
    'Connection': 'keep-alive',
    'Access-Control-Allow-Origin': '*',   // adjust for production
  });

  // Prevent proxy/timeout from closing the connection
  res.socket.setTimeout(0);

  let eventId = 0;

  // Send an initial comment to establish keep-alive
  res.write(':ok\n\n');

  // Send events every 2 seconds
  const intervalId = setInterval(() => {
    eventId++;
    const data = JSON.stringify({
      time: new Date().toISOString(),
      value: Math.random() * 100,
    });

    // Send event with ID and named event type
    res.write(`id: ${eventId}\n`);
    res.write(`event: metric_update\n`);
    res.write(`data: ${data}\n\n`);
  }, 2000);

  // Cleanup on client disconnect
  req.on('close', () => {
    clearInterval(intervalId);
    res.end();
  });
});

app.listen(3000, () => console.log('SSE server on port 3000'));

Notice the res.socket.setTimeout(0) call – it disables Node.js’s default socket timeout, which would otherwise kill the long-lived connection. The comment :ok\n\n serves as an initial keep-alive. Each event block ends with an extra newline, completing the event boundary. When the client disconnects, the 'close' event triggers cleanup to avoid dangling timers.

Client-Side Implementation with EventSource

On the browser, consuming an SSE stream is as simple as instantiating an EventSource with the server URL.

const eventSource = new EventSource('http://localhost:3000/events');

// Listen for the 'metric_update' event we defined on the server
eventSource.addEventListener('metric_update', (event) => {
  const payload = JSON.parse(event.data);
  console.log('Received metric:', payload);
  // Update UI with payload.value and payload.time
});

// Generic message handler (fires for events without a type)
eventSource.onmessage = (event) => {
  console.log('Generic message:', event.data);
};

// Error handling and reconnection
eventSource.onerror = (err) => {
  console.error('EventSource error:', err);
  // The browser will automatically reconnect using Last-Event-ID
};

The EventSource API automatically handles reconnection. If the connection drops, the browser waits the specified retry duration (or a default) and reconnects, sending the Last-Event-ID header so the server can resume. You don't need to write any reconnection logic yourself. The readyState property can be checked: EventSource.CONNECTING, OPEN, or CLOSED.

Python Flask SSE Server

Flask is equally well-suited for SSE. The key is returning a generator-based response with the text/event-stream mimetype.

from flask import Flask, Response, stream_with_context
import time
import json

app = Flask(__name__)

@app.route('/events')
def sse_endpoint():
    def generate():
        event_id = 0
        while True:
            event_id += 1
            data = json.dumps({
                'time': time.strftime('%Y-%m-%d %H:%M:%S'),
                'value': 42 + event_id % 10
            })
            yield f"id: {event_id}\n"
            yield f"event: metric_update\n"
            yield f"data: {data}\n\n"
            time.sleep(2)
    return Response(
        stream_with_context(generate()),
        mimetype='text/event-stream',
        headers={
            'Cache-Control': 'no-cache',
            'Connection': 'keep-alive',
            'Access-Control-Allow-Origin': '*'
        }
    )

if __name__ == '__main__':
    app.run(host='0.0.0.0', port=5000, threaded=True)

Using stream_with_context ensures the generator runs within the request context, which is crucial if you need access to Flask globals like request or session data. The infinite loop writes events every two seconds; the client’s disconnect will eventually stop the generator (Flask detects the broken pipe and stops iteration).

Handling Multiple Clients and Broadcasting

In a real application, you rarely want a single client stream. You need to broadcast events to all connected clients. The typical pattern is to maintain a list of active response objects (or equivalent queue) and push data to each one.

Node.js Broadcast Example

const clients = new Set();

app.get('/events', (req, res) => {
  res.writeHead(200, { /* headers as before */ });
  res.socket.setTimeout(0);

  clients.add(res);
  req.on('close', () => {
    clients.delete(res);
  });
});

// Somewhere in your application, when an event occurs:
function broadcast(eventType, data) {
  const payload = JSON.stringify(data);
  for (const client of clients) {
    client.write(`event: ${eventType}\n`);
    client.write(`data: ${payload}\n\n`);
  }
}

Python Flask Broadcast

import queue

clients = []

@app.route('/events')
def sse():
    q = queue.Queue()
    clients.append(q)
    def generate():
        while True:
            try:
                event = q.get(timeout=30)   # 30s keep-alive
                yield event
            except queue.Empty:
                yield ': heartbeat\n\n'
    return Response(stream_with_context(generate()), mimetype='text/event-stream')

@app.route('/publish', methods=['POST'])
def publish():
    data = request.json
    payload = f"data: {json.dumps(data)}\n\n"
    for client_queue in clients:
        client_queue.put(payload)
    return '', 204

When scaling to multiple processes or servers, you'll need a message bus like Redis Pub/Sub or a dedicated message broker so all instances can receive broadcast events and push them to their local clients. Sticky sessions can help, but a pub/sub backend is more robust.

Best Practices

Security Considerations

SSE endpoints should be protected like any other HTTP endpoint. Because EventSource does not support custom headers (you cannot set Authorization), authentication is often done via cookies (same-origin) or a token passed as a query parameter. If using a query parameter, ensure it is sent over HTTPS to avoid exposure. Validate the Origin header on the server to prevent cross-origin abuse when cookies are used. For sensitive streams, consider requiring a short-lived ticket exchanged from a regular authenticated endpoint before opening the SSE connection.

Also be mindful of the potential for open connections to exhaust server resources. Set reasonable maximum connection limits per client IP, monitor file descriptor usage, and implement back-pressure detection on the stream to avoid overwhelming a slow client.

Conclusion

Server-Sent Events offer a beautifully simple yet powerful mechanism for real-time, server-to-client communication. They leverage ubiquitous HTTP infrastructure, come with built-in browser reconnection and event ID tracking, and require minimal server-side logic. Whether you’re building a live dashboard, a notification feed, or a log viewer, SSE provides a reliable and efficient alternative to polling and WebSockets for unidirectional data flows. By following the patterns and best practices outlined here—proper headers, keep-alive pings, event IDs, and thoughtful client handling—you can deliver robust real-time experiences with remarkably little code. Give SSE a try in your next project; you might find it’s exactly the lightweight streaming solution you’ve been looking for.

🚀 Need a reliable AI agent for your project?

Deploy Hermes Agent in 10 minutes. Managed hosting, zero DevOps.

Get Started — $23.99/mo
← Back to all articles