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:
- Simplicity: The browser API is minimal — a single
EventSourceconstructor — and the server protocol is plain text. - Automatic reconnection: The browser automatically reconnects if the connection drops, with configurable retry intervals.
- HTTP compatibility: SSE works over standard HTTP/HTTPS, passing cleanly through existing infrastructure.
- Lower overhead than WebSockets: No upgrade handshake or framing protocol; just plain text over HTTP.
- Built-in message IDs: Servers can tag events with IDs so clients can resume from where they left off after a disconnection.
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:
data— The actual message payload. Multipledatalines are concatenated with newlines.event— A custom event type that clients can listen for separately.id— An identifier for the message, used for reconnection resumption.retry— The reconnection interval in milliseconds that the browser should use.
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:
- Cookie-based auth: Since
EventSourcesends cookies by default for same-origin requests, session cookies work seamlessly. - Query parameter tokens: Pass the token in the URL, e.g.,
new EventSource('/api/updates?token=abc123'). This is simple but the token may appear in server logs. - Fetch-based streaming: Use the Fetch API with the
ReadableStreaminterface to send custom headers, then parse the SSE format manually.
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
- Buffering: Some servers buffer responses by default. Ensure your framework flushes data immediately. In Node.js, call
res.flushHeaders()or write directly without compression middleware that buffers. - Forgetting the double newline: Each SSE message must end with
\n\n. A single newline will not trigger the client to process the event. - Not handling reconnection logic: If your events are not idempotent or order-dependent, failing to use
Last-Event-IDcan result in duplicate or missing events. - Blocking the event loop: In single-threaded runtimes like Node.js, long-running synchronous work blocks SSE delivery. Use asynchronous patterns and offload heavy work.
SSE vs WebSockets vs Long Polling
Choosing the right technology depends on your requirements:
- SSE is best for server-to-client streaming with simple setup, automatic reconnection, and HTTP compatibility. Choose it when you do not need client-to-server messaging.
- WebSockets are best for bidirectional, low-latency communication such as multiplayer games or collaborative editing. They require a protocol upgrade and more complex server infrastructure.
- Long polling is a fallback for environments where neither SSE nor WebSockets are available, though it is less efficient and more complex to implement correctly.
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.