Skip to main content
โšก Calmops

Server-Sent Events: Real-Time Communication Without WebSockets

Introduction

In the realm of real-time web communication, WebSockets often dominate the conversation. However, Server-Sent Events (SSE) offer a compelling alternative for scenarios where unidirectional server-to-client communication suffices. Established as a W3C standard and supported by all modern browsers, SSE provides a simpler, more reliable solution for many real-time use cases.

In 2026, SSE has gained renewed attention as developers recognize its advantages: automatic reconnection, simplicity of implementation, HTTP/2 compatibility, and seamless operation through firewalls and proxies. This guide explores Server-Sent Events in depth, from basic concepts to advanced patterns and production considerations.

Understanding Server-Sent Events

What Are Server-Sent Events?

Server-Sent Events (SSE) is a technology enabling servers to push data to clients over a single, long-lived HTTP connection. Unlike WebSockets, SSE is unidirectionalโ€”only the server can send messages to the client. This simplicity makes SSE ideal for live updates, notifications, dashboards, and streaming data.

The SSE protocol uses the MIME type text/event-stream and follows a simple message format:

data: {"message": "hello"}

data: second message\n\n

When to Use SSE vs WebSockets

Aspect Server-Sent Events WebSockets
Direction Server โ†’ Client Bidirectional
Protocol HTTP/1.1 or HTTP/2 WebSocket (ws://)
Browser Support Universal Universal
Automatic Reconnection Built-in Manual implementation
Binary Data Limited Full support
Connection Limits ~6 per domain Independent
Firewall Compatibility High May require config
Simplicity Higher Lower

Choose SSE when:

  • You only need server-to-client communication
  • You want simpler implementation
  • Automatic reconnection is important
  • You need HTTP/2 multiplexing
  • You want better firewall/proxy compatibility

Choose WebSockets when:

  • You need bidirectional communication
  • You need to send data back to the server frequently
  • You need binary data transfer
  • You require sub-second latency

The SSE Protocol

Message Format

SSE messages consist of fields, each on a new line:

field: value\n

Supported fields:

  • data: The message payload (required)
  • id: Event identifier for automatic reconnection
  • event: Custom event type
  • retry: Reconnection time in milliseconds
  • comment: Ignored (for keep-alive)

Simple Example

Server response:

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

data: {"temperature": 72}

data: {"temperature": 73}

data: {"temperature": 71}

Event IDs for Reliable Delivery

id: 1
data: {"type": "update", "content": "first message"}

id: 2
data: {"type": "update", "content": "second message"}

id: 3
data: {"type": "update", "content": "third message"}

When the connection drops, the browser automatically reconnects and sends the Last-Event-ID header, allowing the server to resume from where it left off.

Custom Event Types

event: notification
data: {"type": "alert", "message": "New message received"}

event: update
data: {"type": "data", "content": "database updated"}

event: heartbeat
data: ping

The client can listen for specific event types:

const source = new EventSource('/stream');

// Listen for specific events
source.addEventListener('notification', (e) => {
    console.log('Notification:', JSON.parse(e.data));
});

source.addEventListener('update', (e) => {
    console.log('Update:', JSON.parse(e.data));
});

Implementing SSE on the Server

Node.js Implementation

const http = require('http');

const server = http.createServer((req, res) => {
    if (req.url === '/stream' && req.accepts('text/event-stream')) {
        res.writeHead(200, {
            'Content-Type': 'text/event-stream',
            'Cache-Control': 'no-cache',
            'Connection': 'keep-alive',
            'Access-Control-Allow-Origin': '*'
        });

        // Send initial message
        res.write('data: {"status": "connected"}\n\n');

        // Send updates every 2 seconds
        const interval = setInterval(() => {
            const data = {
                timestamp: new Date().toISOString(),
                value: Math.random() * 100
            };
            res.write(`data: ${JSON.stringify(data)}\n\n`);
        }, 2000);

        // Handle client disconnect
        req.on('close', () => {
            clearInterval(interval);
            console.log('Client disconnected');
        });

        // Send comment to keep connection alive
        const keepAlive = setInterval(() => {
            res.write(': keepalive\n\n');
        }, 30000);

        req.on('close', () => clearInterval(keepAlive));
    } else {
        res.writeHead(404);
        res.end('Not Found');
    }
});

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

Python Implementation

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

app = Flask(__name__)

@app.route('/stream')
def stream():
    def generate():
        # Send initial message
        yield 'data: {"status": "connected"}\n\n'
        
        # Stream updates
        for i in range(100):
            data = {
                'timestamp': time.time(),
                'counter': i
            }
            yield f'data: {json.dumps(data)}\n\n'
            time.sleep(1)
    
    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(threaded=True)

Go Implementation

package main

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

func sseHandler(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")
    w.Header().Set("Access-Control-Allow-Origin", "*")

    // Flush headers
    flusher, ok := w.(http.Flusher)
    if !ok {
        http.Error(w, "Streaming unsupported", 500)
        return
    }

    // Send initial message
    fmt.Fprintf(w, "data: {\"status\": \"connected\"}\n\n")
    flusher.Flush()

    // Create ticker for periodic updates
    ticker := time.NewTicker(2 * time.Second)
    defer ticker.Stop()

    for {
        select {
        case <-ticker.C:
            data := map[string]interface{}{
                "timestamp": time.Now().Unix(),
                "value":     time.Now().Unix() % 100,
            }
            fmt.Fprintf(w, "data: %v\n\n", data)
            flusher.Flush()
        case <-r.Context().Done():
            return
        }
    }
}

func main() {
    http.HandleFunc("/stream", sseHandler)
    http.ListenAndServe(":8080", nil)
}

Using Resumable Events

from flask import Flask, request, Response
import time

app = Flask(__name__)

# In-memory storage (use database in production)
events = []

@app.route('/stream')
def stream():
    last_id = request.headers.get('Last-Event-ID', '0')
    
    # Resume from last event
    try:
        resume_index = int(last_id)
    except ValueError:
        resume_index = 0
    
    def generate():
        # Send missed events
        for event in events[resume_index:]:
            yield f"id: {event['id']}\ndata: {event['data']}\n\n"
        
        # Stream new events
        while True:
            if len(events) > resume_index:
                event = events[-1]
                yield f"id: {event['id']}\ndata: {event['data']}\n\n"
                resume_index = event['id']
            time.sleep(1)
    
    return Response(generate(), mimetype='text/event-stream')

# Endpoint to generate events
@app.route('/send', methods=['POST'])
def send_event():
    event_id = len(events)
    # ... handle POST request ...
    events.append({
        'id': event_id,
        'data': '{"message": "new event"}'
    })
    return 'OK'

Implementing SSE on the Client

Basic Usage

const source = new EventSource('/stream');

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

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

source.onerror = (error) => {
    console.error('SSE Error:', error);
};

Handling Reconnection

const source = new EventSource('/stream');

// Track last event ID for resumption
let lastEventId = null;

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

// When reconnecting, Last-Event-ID is automatically sent
source.addEventListener('open', () => {
    console.log('Reconnected, last ID:', lastEventId);
});

Custom Event Handling

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

// Real-time notifications
source.addEventListener('notification', (e) => {
    const data = JSON.parse(e.data);
    showNotification(data.title, data.body);
});

// Dashboard updates
source.addEventListener('dashboard', (e) => {
    const metrics = JSON.parse(e.data);
    updateDashboard(metrics);
});

// Price updates
source.addEventListener('price', (e) => {
    const price = JSON.parse(e.data);
    updatePriceDisplay(price);
});

// Cleanup on page unload
window.addEventListener('beforeunload', () => {
    source.close();
});

Manual Reconnection Control

class SSEManager {
    constructor(url) {
        this.url = url;
        this.source = null;
        this.retryTime = 5000;
        this.maxRetries = 5;
    }

    connect() {
        this.source = new EventSource(this.url);
        
        this.source.onopen = () => {
            console.log('SSE connected');
            this.retryTime = 5000; // Reset on success
        };
        
        this.source.onerror = () => {
            console.log('SSE error, retrying in', this.retryTime);
            this.source.close();
            
            if (this.retryTime < this.maxRetries * 1000) {
                setTimeout(() => this.connect(), this.retryTime);
                this.retryTime *= 2;
            }
        };
        
        return this.source;
    }

    on(event, handler) {
        if (this.source) {
            this.source.addEventListener(event, handler);
        }
    }

    close() {
        if (this.source) {
            this.source.close();
        }
    }
}

// Usage
const sse = new SSEManager('/api/stream');
sse.connect();
sse.on('update', (e) => console.log(e.data));

SSE with HTTP/2

Benefits of HTTP/2

HTTP/2 provides significant advantages for SSE:

  • Multiplexing: Multiple streams over single connection
  • Header Compression: Reduced overhead
  • Server Push: Additional capabilities alongside SSE
  • Connection Reuse: Single connection for multiple resources

Node.js with HTTP/2

const http2 = require('http2');
const fs = require('fs');

const server = http2.createSecureServer({
    key: fs.readFileSync('server.key'),
    cert: fs.readFileSync('server.crt')
});

server.on('stream', (stream, headers) => {
    if (headers[':path'] === '/stream') {
        stream.respond({
            ':status': 200,
            'Content-Type': 'text/event-stream',
            'Cache-Control': 'no-cache',
            'Connection': 'keep-alive'
        });

        // Send initial data
        stream.write('data: {"status": "connected"}\n\n');

        const interval = setInterval(() => {
            const data = { time: Date.now() };
            stream.write(`data: ${JSON.stringify(data)}\n\n`);
        }, 1000);

        stream.on('close', () => clearInterval(interval));
    } else {
        stream.respond({ ':status': 404 });
        stream.end();
    }
});

server.listen(8443, () => {
    console.log('HTTP/2 SSE server running on https://localhost:8443');
});

Production Considerations

Scaling with Message Queues

import redis
import json

r = redis.Redis()

def sse_handler(environ, start_response):
    channel = environ['PATH_INFO'].strip('/')
    
    def generate():
        pubsub = r.pubsub()
        pubsub.subscribe(channel)
        
        for message in pubsub.listen():
            if message['type'] == 'message':
                yield f"data: {message['data']}\n\n"
    
    return generate()

# Publishing events
def publish_event(channel, data):
    r.publish(channel, json.dumps(data))

Connection Limits

Browser limits:

  • Chrome: 6 connections per origin (HTTP/1.1)
  • Firefox: 6 connections per origin
  • HTTP/2: 100+ multiplexed streams

Solutions:

  • Use HTTP/2
  • Implement connection pooling
  • Distribute across subdomains
  • Use CDN for SSE endpoints

Authentication

// Client: Include token in URL
const source = new EventSource('/stream?token=YOUR_JWT_TOKEN');

// Server: Validate token
app.get('/stream', (req, res) => {
    const token = req.query.token;
    try {
        const decoded = jwt.verify(token, SECRET);
        req.user = decoded;
    } catch (e) {
        res.status(401).end();
        return;
    }
    
    // Stream events
});

Heartbeats and Keep-Alive

// Server: Send comment every 30 seconds
setInterval(() => {
    res.write(': heartbeat\n\n');
}, 30000);

// Client: Detect connection issues
const source = new EventSource('/stream');
let lastMessageTime = Date.now();

source.onmessage = () => {
    lastMessageTime = Date.now();
};

// Check for stale connections
setInterval(() => {
    if (Date.now() - lastMessageTime > 60000) {
        console.log('Connection may be stale');
 }
}, 10000);

Real-World Use Cases

Live Dashboards

// Client: Dashboard metrics
const source = new EventSource('/api/metrics/realtime');

source.addEventListener('cpu', (e) => {
    updateCpuGauge(JSON.parse(e.data));
});

source.addEventListener('memory', (e) => {
    updateMemoryChart(JSON.parse(e.data));
});

source.addEventListener('network', (e) => {
    updateNetworkGraph(JSON.parse(e.data));
});

Notification

// Client: User notifications const source = new EventSource Systems


source.addEventListener('new_order', (e) => {
    showToast('New order received!', 'success');
});

source.addEventListener('system_alert', (e) => {
    showAlert(JSON.parse(e.data));
});

source.addEventListener('message', (e) => {
    incrementUnreadCount();
    showChatNotification(JSON.parse(e.data));
});

Live Feeds

// Client: Social media feed
const source = new EventSource('/api/feed/live');

source.addEventListener('post', (e) => {
    const post = JSON.parse(e.data);
    prependToFeed(post);
});

source.addEventListener('like', (e) => {
    updateLikeCount(e.data);
});

source.addEventListener('comment', (e) => {
    appendComment(e.data);
});

Best Practices

  1. Use HTTP/2 When Possible: Better multiplexing and efficiency
  2. Implement Proper Reconnection: Leverage event IDs for reliability
  3. Include Heartbeats: Prevent proxy timeouts
  4. Handle Connection Limits: Plan for browser limits
  5. Secure Your Streams: Use authentication and TLS
  6. Monitor Connections: Track active connections and failures
  7. Graceful Degradation: Have fallback mechanisms ready
  8. Test Across Browsers: Verify consistent behavior

Conclusion

Server-Sent Events provide a robust, simple solution for server-to-client real-time communication. While not suitable for every use case, SSE excels in scenarios involving live updates, notifications, dashboards, and streaming data where unidirectional communication suffices.

By understanding the SSE protocol, implementing proper error handling, and following production best practices, you can build reliable real-time applications that scale effectively. The automatic reconnection, HTTP compatibility, and simplicity of SSE make it an excellent choice for modern web applications.


Resources

Comments