← Back to DevBytes

Server-Sent Events: A Complete Reference Guide

Introduction to Server-Sent Events

Server-Sent Events (SSE) is a standard that allows a web server to push real-time updates to the browser over a single, long-lived HTTP connection. Unlike WebSockets, which provide bidirectional communication, SSE is strictly one-way: from server to client. This simplicity makes it an excellent choice for scenarios where the server needs to stream data to the client without the overhead of a full-duplex connection.

The SSE specification is built on top of standard HTTP, meaning it works through proxies, firewalls, and load balancers without special configuration. It uses the text/event-stream MIME type and a simple text-based protocol that is both human-readable and easy to implement.

Why Server-Sent Events Matter

Before SSE, developers relied on workarounds like long polling or hidden iframes to simulate server push. These techniques were fragile and inefficient. SSE provides a native, standardized alternative with several compelling advantages:

SSE is ideal for use cases such as live news feeds, stock tickers, social media notifications, chat message delivery, progress indicators for long-running tasks, and real-time dashboards. If your application only needs server-to-client streaming, SSE is often the right tool.

Understanding the SSE Protocol

The SSE protocol is a stream of UTF-8 text encoded messages. Each message is separated by a blank line and consists of one or more fields written as field: value pairs. The specification defines four fields:

A minimal SSE response looks like this:

HTTP/1.1 200 OK
Content-Type: text/event-stream
Cache-Control: no-cache
Connection: keep-alive

data: Hello, world!

Comments can be sent by starting a line with a colon. These are ignored by the client but are useful for keeping connections alive through proxies that close idle connections:

: this is a comment, used as a heartbeat

data: {"temperature": 22.5}

Client-Side Usage with EventSource

The browser exposes SSE through the EventSource interface. Creating a connection is as simple as instantiating it with a URL:

const source = new EventSource('/api/updates');

source.onopen = () => {
  console.log('Connection established');
};

source.onmessage = (event) => {
  console.log('Received:', event.data);
};

source.onerror = (event) => {
  console.log('Error occurred, browser will reconnect automatically');
};

Listening for Named Events

Servers can send custom event types using the event field. Clients listen for these using addEventListener:

const source = new EventSource('/api/updates');

source.addEventListener('status', (event) => {
  const data = JSON.parse(event.data);
  console.log('Status update:', data.message);
});

source.addEventListener('alert', (event) => {
  console.log('Alert:', event.data);
});

// Default 'message' event still fires for unnamed events
source.addEventListener('message', (event) => {
  console.log('Default message:', event.data);
});

Handling Reconnection and Last-Event-ID

When the connection drops, the browser automatically reconnects after a delay. On reconnection, it sends the last received event ID in the Last-Event-ID HTTP header, allowing the server to resume the stream from the correct position:

const source = new EventSource('/api/updates');

source.addEventListener('message', (event) => {
  console.log('Event ID:', event.lastEventId);
  console.log('Data:', event.data);
});

Closing the Connection

To stop receiving events and prevent automatic reconnection, call close():

source.close();

Building an SSE Server

Implementing SSE on the server requires setting the correct headers and writing messages in the SSE format. Below are examples in several popular server environments.

Node.js with Express

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

app.get('/api/updates', (req, res) => {
  // Set required SSE headers
  res.writeHead(200, {
    'Content-Type': 'text/event-stream',
    'Cache-Control': 'no-cache',
    'Connection': 'keep-alive'
  });

  let counter = 0;
  let lastEventId = parseInt(req.headers['last-event-id']) || 0;

  const interval = setInterval(() => {
    counter++;
    const payload = JSON.stringify({
      time: new Date().toISOString(),
      value: counter
    });

    // Send an event with an ID
    res.write(`id: ${lastEventId + counter}\n`);
    res.write(`event: update\n`);
    res.write(`data: ${payload}\n\n`);
  }, 1000);

  // Clean up on client disconnect
  req.on('close', () => {
    clearInterval(interval);
    res.end();
  });
});

app.listen(3000, () => {
  console.log('Server running on port 3000');
});

Python with Flask

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

app = Flask(__name__)

@app.route('/api/updates')
def updates():
    def event_stream():
        last_id = int(request.headers.get('Last-Event-ID', 0))
        counter = 0
        while True:
            counter += 1
            data = json.dumps({
                'time': time.strftime('%Y-%m-%dT%H:%M:%S'),
                'value': counter
            })
            yield f"id: {last_id + counter}\nevent: update\ndata: {data}\n\n"
            time.sleep(1)

    return Response(
        event_stream(),
        mimetype='text/event-stream',
        headers={
            'Cache-Control': 'no-cache',
            'Connection': 'keep-alive'
        }
    )

if __name__ == '__main__':
    app.run(threaded=True)

Go with the Standard Library

package main

import (
    "encoding/json"
    "fmt"
    "net/http"
    "time"
)

func updatesHandler(w http.ResponseWriter, r *http.Request) {
    w.Header().Set("Content-Type", "text/event-stream")
    w.Header().Set("Cache-Control", "no-cache")
    w.Header().Set("Connection", "keep-alive")

    flusher, ok := w.(http.Flusher)
    if !ok {
        http.Error(w, "Streaming not supported", http.StatusInternalServerError)
        return
    }

    counter := 0
    for {
        counter++
        payload, _ := json.Marshal(map[string]interface{}{
            "time":  time.Now().Format(time.RFC3339),
            "value": counter,
        })

        fmt.Fprintf(w, "id: %d\nevent: update\ndata: %s\n\n", counter, payload)
        flusher.Flush()

        select {
        case <-r.Context().Done():
            return
        case <-time.After(1 * time.Second):
        }
    }
}

func main() {
    http.HandleFunc("/api/updates", updatesHandler)
    http.ListenAndServe(":3000", nil)
}

Sending JSON Data

SSE payloads are plain text, but JSON is the most common format for structured data. Always serialize your data before writing it to the data field:

// Server side (Node.js)
const user = { id: 42, name: 'Ada', action: 'logged in' };
res.write(`event: user-activity\n`);
res.write(`data: ${JSON.stringify(user)}\n\n`);

// Client side
source.addEventListener('user-activity', (event) => {
  const user = JSON.parse(event.data);
  console.log(`${user.name} ${user.action}`);
});

Authentication and Authorization

The EventSource constructor does not support custom headers, which complicates token-based authentication. There are several common workarounds:

Here is an example of using Fetch to consume SSE with custom headers:

async function connectSSE() {
  const response = await fetch('/api/updates', {
    headers: {
      'Authorization': 'Bearer my-token-here',
      'Accept': 'text/event-stream'
    }
  });

  const reader = response.body.getReader();
  const decoder = new TextDecoder();
  let buffer = '';

  while (true) {
    const { done, value } = await reader.read();
    if (done) break;

    buffer += decoder.decode(value, { stream: true });
    const messages = buffer.split('\n\n');
    buffer = messages.pop();

    for (const message of messages) {
      const lines = message.split('\n');
      let data = '';
      let eventType = 'message';
      for (const line of lines) {
        if (line.startsWith('data: ')) data += line.slice(6);
        if (line.startsWith('event: ')) eventType = line.slice(7);
      }
      console.log(`Event [${eventType}]:`, data);
    }
  }
}

connectSSE();

Note that this approach loses the automatic reconnection provided by EventSource, so you must implement it manually.

Best Practices

Always Set Correct Headers

The three essential headers for SSE responses are Content-Type: text/event-stream, Cache-Control: no-cache, and Connection: keep-alive. Missing any of these can cause browsers or proxies to buffer or close the connection prematurely.

Send Heartbeats

Proxies and load balancers often terminate idle connections. Send a periodic comment line to keep the connection alive:

setInterval(() => {
  res.write(': heartbeat\n\n');
}, 15000);

Handle Client Disconnects

Always listen for the close event on the server and clean up resources. Failing to do so leads to memory leaks and orphaned timers:

req.on('close', () => {
  clearInterval(interval);
  // Release any other resources
});

Use Event IDs for Resumability

Tag every event with a monotonically increasing ID. On reconnection, read the Last-Event-ID header and replay any missed events. This ensures clients never lose data during transient network failures.

Limit Concurrent Connections per Browser

Browsers historically limited the number of simultaneous HTTP connections per domain to six. While HTTP/2 mitigates this, be mindful of opening multiple SSE streams to the same origin. Consider multiplexing multiple event types over a single connection instead.

Compress Responses When Possible

Enable gzip or brotli compression at the proxy or server level. SSE responses are text-based and compress well, reducing bandwidth costs significantly for high-frequency streams.

Use HTTPS in Production

Always serve SSE over HTTPS. Plain HTTP connections are subject to interference from intermediary proxies that may buffer or modify the stream, breaking real-time delivery.

Common Pitfalls

SSE vs WebSockets vs Long Polling

Choosing the right technology depends on your requirements:

Conclusion

Server-Sent Events provide a robust, standardized, and refreshingly simple way to push real-time data from server to browser. By leveraging plain HTTP, SSE works seamlessly with existing infrastructure while offering built-in reconnection and event resumption. For any application that needs one-way streaming — whether that is live notifications, dashboards, or progress updates — SSE is often the most pragmatic choice, striking an excellent balance between capability and simplicity. By following the best practices outlined in this guide, you can build reliable, efficient real-time features that scale gracefully and degrade gracefully in the face of network interruptions.

— Ad —

Google AdSense will appear here after approval

← Back to all articles