Introduction
Real‑time communication has become a cornerstone of modern web applications. From live sports scores to collaborative editing tools, users expect instant updates without the need to manually refresh a page. While WebSockets often steal the spotlight, Server‑Sent Events (SSE) provide a simpler, standards‑based alternative for one‑way streaming from server to client.
In this article we will explore SSE from the ground up:
- What SSE is and how it differs from other real‑time techniques.
- The wire protocol that powers SSE, including headers and event formatting.
- Server‑side implementations in popular runtimes (Node.js, Python, Go, Java).
- Client‑side consumption via the native
EventSourceAPI, custom events, and reconnection strategies. - Best practices for security, scaling, and reliability.
- A handful of real‑world scenarios where SSE shines.
By the end you’ll be equipped to decide when SSE is the right tool for your project, and you’ll have concrete code you can copy‑paste into production.
1. What Are Server‑Sent Events?
Server‑Sent Events (SSE) are a W3C‑standard mechanism that allows a server to push a stream of text‑based events to a browser over a single long‑lived HTTP connection. The key characteristics are:
| Feature | SSE | WebSocket | Long Polling |
|---|---|---|---|
| Directionality | Server → client (unidirectional) | Full duplex (bidirectional) | Client → server request → server response |
| Transport | HTTP/1.1 (or HTTP/2) | WS (upgraded HTTP) | Repeated HTTP requests |
| Protocol overhead | Minimal (plain text) | Binary framing, extra handshake | Repeated request/response overhead |
| Browser support | Native in all modern browsers (except IE) | Native in most modern browsers | Works everywhere |
| Use case | Live feeds, notifications, dashboards | Chat, gaming, collaborative editing | Simple fallback for low‑frequency updates |
Because SSE leverages plain HTTP, it works seamlessly with existing infrastructure (load balancers, proxies, CDNs) that already understand HTTP. No special ports or protocols are required, which simplifies deployment and firewall configuration.
2. How SSE Works Under the Hood
2.1 HTTP Request / Response Cycle
When a browser wants to receive an event stream, it creates an EventSource object pointing to a URL:
const source = new EventSource('/events');
The browser issues a GET request:
GET /events HTTP/1.1
Host: example.com
Accept: text/event-stream
Cache-Control: no-cache
The server must respond with:
HTTP/1.1 200 OK
Content-Type: text/event-stream
Cache-Control: no-cache
Connection: keep-alive
Content-Type: text/event-streamtells the client to treat the response as an event stream.Cache-Control: no-cacheprevents intermediate caches from storing the response.Connection: keep-alive(or HTTP/2) ensures the connection stays open.
Once the headers are sent, the server does not close the response. Instead, it continuously writes event blocks separated by a double newline (\n\n). Each block can contain the following fields:
| Field | Syntax | Description |
|---|---|---|
event | event: <event-name> | Optional custom event type (defaults to "message"). |
data | data: <payload> | The payload. Multiple data: lines are concatenated with \n. |
id | id: <last-event-id> | Optional identifier used for reconnection. |
retry | retry: <ms> | Suggests a reconnection delay in milliseconds. |
comment | : <comment> | Lines starting with : are ignored by the client (useful for keep‑alive). |
Example event block:
id: 42
event: price-update
data: {"symbol":"AAPL","price":174.32}
retry: 3000
The double newline after retry: tells the client that the event is complete. The client parses each block and dispatches a corresponding MessageEvent object.
2.2 Automatic Reconnection
If the connection drops, the browser automatically reconnects after the retry interval (default 3 seconds). It also sends the last received id in the Last-Event-ID header:
GET /events HTTP/1.1
Host: example.com
Accept: text/event-stream
Last-Event-ID: 42
The server can use this header to resume the stream from the correct point, preventing data loss. Implementing this logic is optional but recommended for reliability.
2.3 SSE Over HTTP/2
With HTTP/2, SSE benefits from multiplexing and header compression, but the wire format stays the same. Many CDNs (e.g., Cloudflare) support HTTP/2 for SSE out of the box, providing lower latency and better connection reuse.
3. Comparison with Alternative Real‑Time Techniques
3.1 WebSockets
- Bidirectional: WebSockets allow client → server messages without extra HTTP requests.
- Binary support: Useful for non‑text data (e.g., protobuf).
- Higher overhead: Requires an upgrade handshake (
Upgrade: websocket) and framing overhead. - Complexity: Server implementation often needs a dedicated library or protocol handling.
When to choose WebSockets: Interactive games, chat applications, collaborative editors where the client must frequently send data back to the server.
3.2 Long Polling
- Compatibility: Works everywhere, including very old browsers.
- Higher latency: Each poll incurs a full request/response cycle.
- Server load: Frequent new connections increase overhead.
When to choose Long Polling: Environments where only occasional updates are needed and you must support legacy browsers without SSE.
3.3 HTTP/2 Server Push
- Push resources: Designed for static assets (CSS, JS), not for arbitrary data streams.
- Limited control: Server can’t arbitrarily trigger pushes after the initial response.
When to choose Server Push: Pre‑loading critical assets for performance, not real‑time data.
4. Browser Support & Polyfills
| Browser | Version | SSE Support |
|---|---|---|
| Chrome | 6+ | ✅ |
| Firefox | 6+ | ✅ |
| Safari | 5+ | ✅ |
| Edge (Chromium) | 79+ | ✅ |
| Edge (Legacy) | 12‑18 | ❌ (no native support) |
| Internet Explorer | Any | ❌ |
For the few browsers lacking native support (IE, old Edge), you can use a polyfill that falls back to long polling or fetch with ReadableStream. A popular open‑source polyfill is eventsource-polyfill:
<script src="https://cdn.jsdelivr.net/npm/event-source-polyfill@1.0.20/dist/eventsource.min.js"></script>
The polyfill automatically detects missing EventSource and replaces it with a compatible implementation.
5. Implementing SSE on the Server
Below are concise, production‑ready examples for four popular runtimes.
5.1 Node.js (Express)
// server.js
const express = require('express');
const app = express();
app.get('/events', (req, res) => {
// Set required headers
res.set({
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
});
res.flushHeaders(); // ensures headers are sent immediately
// Optional: send a comment as a keep‑alive every 15 seconds
const keepAlive = setInterval(() => {
res.write(`: keep-alive ${Date.now()}\n\n`);
}, 15000);
// Simulate a data source (e.g., stock ticker)
const interval = setInterval(() => {
const payload = {
symbol: 'AAPL',
price: (170 + Math.random() * 5).toFixed(2),
};
const id = Date.now();
res.write(`id: ${id}\n`);
res.write(`event: price-update\n`);
res.write(`data: ${JSON.stringify(payload)}\n\n`);
}, 3000);
// Cleanup on client disconnect
req.on('close', () => {
clearInterval(interval);
clearInterval(keepAlive);
res.end();
});
});
app.listen(3000, () => console.log('SSE server listening on :3000'));
Key points:
res.flushHeaders()forces the headers to be sent, preventing the client from waiting.- The
keep-alivecomment prevents some proxies from timing out idle connections. - Listening to
req.on('close')ensures we stop background timers when the client disconnects, avoiding memory leaks.
5.2 Python (Flask)
# app.py
from flask import Flask, Response, stream_with_context
import time
import json
import random
app = Flask(__name__)
def event_stream():
# Send an initial comment as a heartbeat
yield ': connected\n\n'
while True:
payload = {
'symbol': 'TSLA',
'price': round(800 + random.random() * 20, 2)
}
data = json.dumps(payload)
# Build SSE block
yield f"id: {int(time.time())}\n"
yield "event: price-update\n"
yield f"data: {data}\n\n"
time.sleep(5)
@app.route('/events')
def sse():
headers = {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
"Connection": "keep-alive"
}
return Response(stream_with_context(event_stream()), headers=headers)
if __name__ == '__main__':
app.run(threaded=True, port=5000)
Notes:
stream_with_contextensures Flask’s request context stays alive while streaming.- Flask’s built‑in development server is single‑threaded; for production use Gunicorn with
--worker-class geventor uWSGI.
5.3 Go (net/http)
// main.go
package main
import (
"encoding/json"
"fmt"
"log"
"math/rand"
"net/http"
"time"
)
type Price struct {
Symbol string `json:"symbol"`
Price float64 `json:"price"`
}
func sseHandler(w http.ResponseWriter, r *http.Request) {
// Set headers
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
// Flusher interface is required to push data immediately
flusher, ok := w.(http.Flusher)
if !ok {
http.Error(w, "Streaming unsupported!", http.StatusInternalServerError)
return
}
// Heartbeat every 30 seconds
heartbeat := time.NewTicker(30 * time.Second)
defer heartbeat.Stop()
// Simulated price updates
ticker := time.NewTicker(2 * time.Second)
defer ticker.Stop()
for {
select {
case <-r.Context().Done():
// Client closed connection
return
case <-heartbeat.C:
fmt.Fprintf(w, ": heartbeat %d\n\n", time.Now().Unix())
flusher.Flush()
case <-ticker.C:
price := Price{
Symbol: "GOOG",
Price: 2600 + rand.Float64()*10,
}
data, _ := json.Marshal(price)
fmt.Fprintf(w, "id: %d\n", time.Now().Unix())
fmt.Fprintf(w, "event: price-update\n")
fmt.Fprintf(w, "data: %s\n\n", data)
flusher.Flush()
}
}
}
func main() {
http.HandleFunc("/events", sseHandler)
log.Println("SSE server listening on :8080")
log.Fatal(http.ListenAndServe(":8080", nil))
}
Explanation:
- The
http.Flusherinterface forces the server to send buffered data immediately. - The
r.Context().Done()channel detects client disconnects, allowing graceful termination. - Heartbeat comments keep proxies from closing idle connections.
5.4 Java (Spring Boot)
// SseController.java
package com.example.sse;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import reactor.core.publisher.Flux;
import java.time.Duration;
import java.time.Instant;
import java.util.Random;
@RestController
public class SseController {
private final Random random = new Random();
@GetMapping(value = "/events", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<String> streamEvents() {
// Emit a new event every second
return Flux.interval(Duration.ofSeconds(1))
.map(seq -> {
double price = 100 + random.nextDouble() * 5;
String data = String.format("{\"symbol\":\"MSFT\",\"price\":%.2f}", price);
return "id: " + Instant.now().toEpochMilli() + "\n" +
"event: price-update\n" +
"data: " + data + "\n\n";
});
}
}
Key points:
- Spring WebFlux’s
Fluxprovides a reactive stream that automatically writes to the response as SSE. - The
produces = MediaType.TEXT_EVENT_STREAM_VALUEheader sets the correct MIME type. - For classic Spring MVC, you can use
SseEmitter.
6. Consuming SSE on the Client
6.1 Basic Usage
const source = new EventSource('/events');
source.onmessage = (event) => {
console.log('Default message:', event.data);
};
source.onerror = (err) => {
console.error('SSE error', err);
};
The onmessage handler receives MessageEvent objects whose data property contains the raw payload (as a string). If you sent JSON, parse it:
source.onmessage = (e) => {
const payload = JSON.parse(e.data);
// Update UI
};
6.2 Listening to Custom Events
If the server sends event: price-update, you can attach a listener:
source.addEventListener('price-update', (e) => {
const { symbol, price } = JSON.parse(e.data);
console.log(`${symbol}: $${price}`);
});
Custom events are useful for routing logic without having to inspect the payload.
6.3 Handling Reconnection & Last‑Event‑ID
The browser automatically reconnects, but you can react to the open event to know when the connection is ready:
source.addEventListener('open', () => console.log('SSE connection opened'));
If you need to persist the last event ID across page reloads (e.g., when the user navigates away and returns), store it in localStorage:
source.addEventListener('price-update', (e) => {
localStorage.setItem('lastEventId', e.lastEventId);
});
When creating a new EventSource, you can pass the stored ID via the Last-Event-ID header only by using a polyfill because native EventSource does not expose a way to set custom headers. With the polyfill:
const source = new EventSourcePolyfill('/events', {
headers: {
'Last-Event-ID': localStorage.getItem('lastEventId') || '',
},
});
6.4 Canceling the Stream
// Close the connection when it’s no longer needed
source.close();
Always close the stream when navigating away from a page to avoid lingering network connections.
7. Best Practices for Production‑Ready SSE
7.1 Security
CORS – If your SSE endpoint lives on a different origin, configure CORS correctly:
// Express example app.use((req, res, next) => { res.setHeader('Access-Control-Allow-Origin', 'https://myapp.com'); next(); });Authentication – Use cookies or token‑based auth (e.g., JWT) before establishing the connection. For token auth you can embed the token in the URL query string (HTTPS only) or use the polyfill to send an
Authorizationheader.Rate Limiting – Prevent a single client from opening many SSE connections. Implement per‑IP or per‑user limits at the load balancer or application layer.
7.2 Scaling & Load Balancing
- Sticky Sessions – Some SSE implementations rely on in‑memory state (e.g., tracking last IDs). Enable sticky sessions on your load balancer if you keep state locally.
- Stateless Design – Prefer storing state in a database or cache (Redis) so any server can serve any client.
- Horizontal Scaling – Deploy multiple instances behind an HTTP load balancer that supports WebSocket‑compatible connection upgrades (most modern LBs do, e.g., NGINX, HAProxy, AWS ALB, Cloudflare).
- CDN Edge Streaming – Services like Cloudflare Workers can proxy SSE streams to edge locations, reducing latency for geographically dispersed users.
7.3 Heartbeats & Timeouts
Many proxies (NGINX, Apache) close idle connections after 60 seconds. Send comment lines (: keep-alive) at a regular interval (15–30 seconds) to keep the connection alive.
setInterval(() => {
res.write(`: ping ${Date.now()}\n\n`);
}, 25000);
7.4 Event IDs & Replay
- Always include an
idfield. This enables the client to request missed events after a reconnect. - Store the last event ID per client in a Redis Sorted Set or a database. On reconnection, read the
Last-Event-IDheader and replay any missed events.
7.5 Content-Type & Encoding
- Stick to UTF‑8. The spec mandates UTF‑8 encoding for
text/event-stream. Avoid binary data; if you must send binary, base64‑encode it.
7.6 Error Handling on the Client
source.onerror = (e) => {
if (source.readyState === EventSource.CLOSED) {
console.warn('SSE connection closed.');
} else {
console.error('SSE error', e);
}
};
The readyState can be:
0– CONNECTING1– OPEN2– CLOSED
Use this to decide whether to attempt manual reconnection (e.g., after a 401 Unauthorized).
8. Real‑World Use Cases
8.1 Live Dashboards & Monitoring
Monitoring tools (Grafana, Kibana) often need to push metric updates in near‑real‑time. SSE’s low overhead makes it perfect for streaming thousands of numeric updates per second to a dashboard.
8.2 Server‑Side Notifications
Social platforms use SSE for in‑app notifications (likes, comments). Because notifications are one‑way (server → client) and rarely require client‑to‑server messages, SSE is a simpler alternative to WebSockets.
8.3 Stock Tickers & Financial Feeds
Financial data streams require low latency and high reliability. SSE’s built‑in reconnection with Last-Event-ID helps guarantee no price updates are missed after a temporary network glitch.
8.4 Collaborative Editing (Read‑Only Views)
When multiple users view a shared document, you can push change deltas via SSE to keep everyone’s view synchronized. Since edits themselves are sent via regular HTTP POST requests, SSE handles the broadcast efficiently.
8.5 IoT Device Telemetry
Edge devices often push telemetry to a central server. Consumers (e.g., admin consoles) can subscribe via SSE to receive updates without opening a full WebSocket channel, reducing server resource consumption.
9. Testing & Debugging SSE
9.1 Using curl
curl -N -H "Accept: text/event-stream" http://localhost:3000/events
-Ndisables buffering, letting you see events as they arrive.- Verify that the
Content-Typeheader is correct and that events are separated by double newlines.
9.2 Browser DevTools
- Network tab – Look for the request, check the Headers for
text/event-stream, and watch the Response stream in real time. - Console – Log
source.readyStateandsource.urlto confirm the connection status.
9.3 Unit Testing (Node)
Use supertest + jest to assert that the endpoint sends correct headers and begins streaming:
const request = require('supertest');
const app = require('../app');
test('SSE endpoint sets proper headers', async () => {
const res = await request(app)
.get('/events')
.set('Accept', 'text/event-stream')
.expect('Content-Type', /text\/event-stream/)
.expect('Cache-Control', /no-cache/);
// Close the connection after a short delay
res.res.destroy();
});
9.4 Load Testing
Tools like k6 or Artillery can simulate thousands of concurrent SSE connections:
k6 run --vus 5000 --duration 2m sse_test.js
In the script, use the http.get with responseType: 'text' and parse the stream manually.
10. Performance & Scaling Considerations
| Concern | Mitigation |
|---|---|
| Idle connections | Send comment heartbeats every 15‑30 s; configure proxy timeout > heartbeat interval. |
| Memory usage per connection | Use event-driven servers (Node.js, Go, Netty) that keep per‑connection overhead low. |
| Broadcast to many clients | Publish events to a message broker (Redis Pub/Sub, NATS, Kafka) and let each SSE worker subscribe and forward. |
| Back‑pressure | If a client lags, drop events or send a “slow‑down” message; avoid buffering unbounded data. |
| TLS overhead | Use HTTP/2 with multiplexed streams to amortize TLS handshake cost across many connections. |
| Failover | Deploy multiple SSE nodes behind a load balancer with health checks; enable sticky sessions if stateful. |
A typical production setup might look like:
[Client] <--HTTPS--> [Load Balancer (ALB/NGINX)] <--HTTP/2--> [SSE Service (Node.js)] <--Redis Pub/Sub--> [Event Producer (Microservice)]
The producer writes events to Redis; every SSE instance subscribes and pushes them to its connected clients. This decouples event generation from delivery, making the system horizontally scalable.
11. Common Pitfalls & How to Avoid Them
| Symptom | Likely Cause | Fix |
|---|---|---|
| Connection drops after 60 s | Proxy timeout without heartbeats. | Add comment : keep-alive every 20 s. |
| Duplicate events after reconnect | Server does not honor Last-Event-ID. | Store last ID per client and replay missed events. |
data: lines truncated | Server sends binary data without proper encoding. | Encode binary as Base64 or send as JSON string. |
| CORS errors | Missing Access-Control-Allow-Origin. | Set appropriate CORS headers or serve SSE from same origin. |
| High CPU on server | Using setInterval with heavy payload generation. | Offload computation to a worker process or use a message broker. |
| Browser shows “EventSource error” repeatedly | Server returns non‑200 status (e.g., 401). | Ensure authentication succeeds before establishing the stream. |
12. Conclusion
Server‑Sent Events provide a lightweight, standards‑based way to push real‑time data from server to browser. Their strengths lie in:
- Simplicity – Only a few lines of server code and a native
EventSourceon the client. - Compatibility – Works over standard HTTP/1.1 and HTTP/2, traverses firewalls, and is supported by all major browsers.
- Reliability – Automatic reconnection,
Last-Event-IDreplay, and built‑in heartbeats keep streams alive even in flaky networks.
While WebSockets remain the go‑to solution for full‑duplex communication, SSE shines in scenarios where the data flow is unidirectional, the payload is textual, and you prefer to avoid the added complexity of a custom binary protocol. By following the best practices outlined—proper headers, heartbeats, authentication, and horizontal scaling—you can confidently deploy SSE in production environments ranging from live dashboards to financial tickers.
Give SSE a try in your next project; you may discover that the “simpler” solution is exactly what you need.
Resources
- Server‑Sent Events (MDN) – Comprehensive specification and browser compatibility table.
- HTML Living Standard – EventSource – Official W3C spec describing the wire format and reconnection behavior.
- EventSource Polyfill (GitHub) – Polyfill that adds SSE support to browsers lacking native implementation and allows custom headers.