Server-Sent Events (SSE) in JavaScript: One-Way Real-Time Data from Server to Client
Introduction
When building real-time web applications, you often need the server to push updates to the client without the client having to ask repeatedly. Think of a live stock ticker, a news feed, or a notification system. While WebSocket is the go-to solution for full-duplex (two-way) communication, many real-time scenarios only need data flowing in one direction: from the server to the client.
This is exactly where Server-Sent Events (SSE) shine. SSE is a browser-native API built on top of plain HTTP that lets the server push messages to the client through a persistent connection. It is simpler to set up than WebSocket, supports automatic reconnection out of the box, and works seamlessly with existing HTTP infrastructure like proxies, load balancers, and authentication middleware.
In this guide, you will learn what Server-Sent Events are, how to use the EventSource API to connect to a server, how to handle different event types, how auto-reconnection works, and when to choose SSE over WebSocket (and vice versa).
What Are Server-Sent Events? (One-Way Server Push)
Server-Sent Events (SSE) is a standard that allows a web server to send real-time updates to the browser over a single, long-lived HTTP connection. Unlike traditional request-response patterns where the client must poll the server for new data, SSE keeps the connection open and lets the server send data whenever it wants.
The Key Characteristics of SSE
- One-way communication: Data flows only from server to client. If the client needs to send data to the server, it uses a separate HTTP request (like
fetchorXMLHttpRequest). - Built on HTTP: SSE uses a standard HTTP connection with a special MIME type (
text/event-stream). No protocol upgrade is needed. - Text-based: SSE transmits data as UTF-8 text. Binary data is not natively supported (you would need to encode it, for example as Base64).
- Automatic reconnection: If the connection drops, the browser automatically attempts to reconnect.
- Event IDs: The server can assign IDs to events, and the browser sends the last received ID on reconnection, enabling the server to resume from where it left off.
How SSE Works Under the Hood
The process is straightforward:
- The client creates an
EventSourceobject pointing to a server URL. - The browser sends a standard
GETrequest to that URL. - The server responds with
Content-Type: text/event-streamand keeps the connection open. - The server sends data as plain text messages, formatted according to the SSE protocol.
- The browser parses incoming messages and fires JavaScript events.
Here is what the server response stream looks like:
HTTP/1.1 200 OK
Content-Type: text/event-stream
Cache-Control: no-cache
Connection: keep-alive
data: Hello, this is the first message
data: This is the second message
data: {"user": "Alice", "action": "logged in"}
Each message is a block of text ending with a blank line (\n\n). The data: prefix indicates the message payload.
SSE is part of the HTML Living Standard, not a separate protocol. It is natively supported in all modern browsers (Chrome, Firefox, Safari, Edge). Internet Explorer never supported it, but IE is no longer relevant.
EventSource: Connecting to the Server
The browser provides the EventSource interface to connect to an SSE endpoint. Creating a connection is remarkably simple.
Basic Connection
const eventSource = new EventSource('/api/notifications');
That single line opens a persistent connection to /api/notifications. The browser sends a GET request and expects the server to respond with Content-Type: text/event-stream.
Connection with Credentials (Cookies)
By default, EventSource does not send cookies for cross-origin requests. If you need to include credentials, pass a second argument:
const eventSource = new EventSource('https://other-domain.com/events', {
withCredentials: true
});
This sets the withCredentials flag, similar to how fetch works with credentials: 'include'.
The readyState Property
The EventSource object has a readyState property that tells you the current connection status:
| Value | Constant | Meaning |
|---|---|---|
0 | EventSource.CONNECTING | Connecting (or reconnecting) |
1 | EventSource.OPEN | Connection is open and active |
2 | EventSource.CLOSED | Connection is closed |
const eventSource = new EventSource('/api/stream');
console.log(eventSource.readyState); // 0 (CONNECTING)
eventSource.onopen = () => {
console.log(eventSource.readyState); // 1 (OPEN)
};
The url Property
You can check which URL the EventSource is connected to:
const eventSource = new EventSource('/api/stream');
console.log(eventSource.url); // "http://localhost:3000/api/stream" (full resolved URL)
Closing the Connection
To permanently close the connection (no auto-reconnection), call .close():
eventSource.close();
console.log(eventSource.readyState); // 2 (CLOSED)
Once you call .close(), the EventSource object cannot be reused. You must create a new one if you want to reconnect.
After calling eventSource.close(), the object is done. Auto-reconnection will not happen. This is how you intentionally stop listening for events.
Events: open, message, Custom Events, error
The EventSource object fires several types of events. You handle them using addEventListener or the corresponding on<event> properties.
The open Event
Fired when the connection is successfully established:
const eventSource = new EventSource('/api/stream');
eventSource.addEventListener('open', (event) => {
console.log('Connection opened!');
console.log('readyState:', eventSource.readyState); // 1
});
// Or using the property shorthand:
eventSource.onopen = (event) => {
console.log('Connected to server');
};
The message Event
This is the most important event. It fires whenever the server sends a message without a custom event type (or with event: message):
eventSource.addEventListener('message', (event) => {
console.log('New message:', event.data);
console.log('Last event ID:', event.lastEventId);
console.log('Origin:', event.origin);
});
// Or:
eventSource.onmessage = (event) => {
console.log('Received:', event.data);
};
The event object (a MessageEvent) has the following useful properties:
| Property | Description |
|---|---|
event.data | The message payload (always a string) |
event.lastEventId | The last event ID set by the server (string) |
event.origin | The origin of the server that sent the event |
Handling JSON Data
Since event.data is always a string, you typically parse it as JSON for structured data:
eventSource.onmessage = (event) => {
const data = JSON.parse(event.data);
console.log('User:', data.user);
console.log('Action:', data.action);
};
The corresponding server output might look like:
data: {"user": "Alice", "action": "logged in"}
Multi-Line Data
The server can send multi-line data by using multiple data: lines. The browser joins them with newline characters:
Server sends:
data: Line one
data: Line two
data: Line three
Client receives:
eventSource.onmessage = (event) => {
console.log(event.data);
// Output:
// "Line one\nLine two\nLine three"
};
Custom Events
This is one of SSE's most powerful features. The server can send named events using the event: field, and the client listens for them separately:
Server sends:
event: userLogin
data: {"user": "Alice", "time": "2024-01-15T10:30:00Z"}
event: notification
data: {"text": "New comment on your post", "unread": 5}
data: This is a regular message (no event field = "message" event)
Client listens for each event type individually:
const eventSource = new EventSource('/api/stream');
// Listen for custom "userLogin" events
eventSource.addEventListener('userLogin', (event) => {
const data = JSON.parse(event.data);
console.log(`${data.user} logged in at ${data.time}`);
});
// Listen for custom "notification" events
eventSource.addEventListener('notification', (event) => {
const data = JSON.parse(event.data);
console.log(`Notification: ${data.text} (${data.unread} unread)`);
});
// Listen for generic messages (no custom event type)
eventSource.onmessage = (event) => {
console.log('Generic message:', event.data);
};
Output:
Alice logged in at 2024-01-15T10:30:00Z
Notification: New comment on your post (5 unread)
Generic message: This is a regular message (no event field = "message" event)
Custom events are incredibly useful for separating different types of server data. Instead of sending everything through onmessage and using a type field in JSON, let the SSE protocol do the routing for you. Each event type gets its own handler, making the code cleaner and easier to maintain.
The error Event
Fired when something goes wrong: the connection fails, the server closes the connection, or a network error occurs:
eventSource.addEventListener('error', (event) => {
if (eventSource.readyState === EventSource.CONNECTING) {
console.log('Connection lost. Attempting to reconnect...');
} else if (eventSource.readyState === EventSource.CLOSED) {
console.log('Connection was closed by the server.');
}
});
// Or:
eventSource.onerror = (event) => {
console.error('EventSource error:', event);
};
The error event does not provide detailed error information. The event object is a generic Event, not an ErrorEvent. You cannot get an error message or status code from it. You must rely on readyState to understand what happened.
Complete Example: Putting It All Together
const eventSource = new EventSource('/api/live-feed');
eventSource.onopen = () => {
console.log('✅ Connected to live feed');
};
eventSource.onmessage = (event) => {
console.log('📨 Message:', event.data);
};
eventSource.addEventListener('alert', (event) => {
const data = JSON.parse(event.data);
console.log('🚨 Alert:', data.message);
});
eventSource.addEventListener('stats', (event) => {
const data = JSON.parse(event.data);
console.log(`📊 Active users: ${data.activeUsers}`);
});
eventSource.onerror = () => {
if (eventSource.readyState === EventSource.CONNECTING) {
console.log('🔄 Reconnecting...');
} else {
console.log('❌ Connection closed');
}
};
The SSE Protocol Format
Before diving into auto-reconnection, it helps to understand the full SSE protocol format that the server sends. Each message is a block of lines, with each line having the format field: value. Messages are separated by blank lines.
Available Fields
| Field | Description |
|---|---|
data: | The message payload. Multiple data: lines are joined with \n. |
event: | Custom event name. If omitted, the message event is fired. |
id: | Sets the last event ID. Sent by the browser on reconnection. |
retry: | Sets the reconnection delay in milliseconds. |
A Full Server Stream Example
retry: 3000
id: 1
data: First message
id: 2
event: update
data: {"temperature": 22.5}
id: 3
data: Multi-line message
data: continues on this line
: this is a comment, ignored by the browser
id: 4
event: alert
data: Server maintenance in 10 minutes
Lines starting with : are comments. The server can use them as keep-alive signals to prevent proxies and load balancers from closing an idle connection:
: heartbeat
Auto-Reconnection
One of SSE's standout features is built-in automatic reconnection. When the connection drops (server restart, network glitch, etc.), the browser automatically tries to reconnect. You do not need to write any reconnection logic.
How Auto-Reconnection Works
- The connection drops (server closes it, network error, etc.).
- The browser fires an
errorevent. - The browser waits for a retry delay (default is typically a few seconds, browser-dependent).
- The browser sends a new
GETrequest to the same URL. - If the server had sent an
id:field, the browser includes aLast-Event-IDHTTP header in the reconnection request.
The retry Field
The server can control the reconnection delay using the retry: field:
retry: 5000
data: Reconnection delay set to 5 seconds
After receiving this, the browser will wait 5000 milliseconds before attempting to reconnect if the connection drops.
retry: 1000
You can set it to a lower value for time-critical applications, or a higher value to reduce server load.
The Last-Event-ID Header
When the server sends messages with id: fields, the browser remembers the last received ID. On reconnection, it sends this ID in the Last-Event-ID HTTP header:
Server sends before disconnection:
id: 42
data: Message forty-two
id: 43
data: Message forty-three
Connection drops. Browser reconnects with:
GET /api/stream HTTP/1.1
Last-Event-ID: 43
The server can then check this header and resume sending events starting from ID 44, so the client does not miss any messages.
Server-Side Reconnection Handling (Node.js Example)
Here is a simple Node.js server that handles SSE with reconnection support:
// server.js (Node.js with Express)
const express = require('express');
const app = express();
let messageId = 0;
app.get('/api/stream', (req, res) => {
// Set SSE headers
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive'
});
// Check if the client is reconnecting
const lastEventId = req.headers['last-event-id'];
if (lastEventId) {
console.log(`Client reconnected. Last received ID: ${lastEventId}`);
// Resume from where the client left off
messageId = parseInt(lastEventId, 10);
}
// Send a retry interval
res.write('retry: 3000\n\n');
// Send a message every 2 seconds
const interval = setInterval(() => {
messageId++;
res.write(`id: ${messageId}\n`);
res.write(`data: Message #${messageId} at ${new Date().toISOString()}\n\n`);
}, 2000);
// Clean up when the client disconnects
req.on('close', () => {
clearInterval(interval);
console.log('Client disconnected');
});
});
app.listen(3000, () => console.log('SSE server running on port 3000'));
And the corresponding client:
const eventSource = new EventSource('/api/stream');
eventSource.onopen = () => {
console.log('Connected');
};
eventSource.onmessage = (event) => {
console.log(`[ID: ${event.lastEventId}] ${event.data}`);
};
eventSource.onerror = () => {
if (eventSource.readyState === EventSource.CONNECTING) {
console.log('Reconnecting...');
}
};
Output:
Connected
[ID: 1] Message #1 at 2024-01-15T10:00:00.000Z
[ID: 2] Message #2 at 2024-01-15T10:00:02.000Z
[ID: 3] Message #3 at 2024-01-15T10:00:04.000Z
Reconnecting... // <-- server restarted
Connected
[ID: 4] Message #4 at 2024-01-15T10:00:08.000Z // <-- resumed seamlessly
Stopping Auto-Reconnection
If the server wants to tell the client to stop reconnecting, it should respond with an HTTP status code that is not 200, or with a Content-Type that is not text/event-stream. For example, responding with 204 No Content will cause the browser to fire an error event and set readyState to CLOSED without attempting to reconnect.
On the client side, you stop reconnection by calling .close():
eventSource.onerror = () => {
// Stop reconnecting after an error
console.log('Error occurred. Closing connection.');
eventSource.close();
};
If you want to implement custom reconnection logic (for example, exponential backoff), you need to close the EventSource and create a new one yourself:
let retryDelay = 1000;
function connect() {
const eventSource = new EventSource('/api/stream');
eventSource.onopen = () => {
retryDelay = 1000; // Reset delay on successful connection
};
eventSource.onmessage = (event) => {
console.log(event.data);
};
eventSource.onerror = () => {
eventSource.close(); // Stop built-in reconnection
console.log(`Reconnecting in ${retryDelay}ms...`);
setTimeout(connect, retryDelay);
retryDelay = Math.min(retryDelay * 2, 30000); // Exponential backoff, max 30s
};
}
connect();
Cross-Origin SSE Requests
EventSource supports cross-origin connections, but the server must include proper CORS headers:
Access-Control-Allow-Origin: https://your-frontend.com
Access-Control-Allow-Credentials: true
On the client, enable credentials:
const eventSource = new EventSource('https://api.example.com/stream', {
withCredentials: true
});
Unlike fetch, you cannot set custom headers on an EventSource request. The browser sends a standard GET request, and you can only control the withCredentials flag. If you need to send authentication tokens, you have a few options:
- Cookies: Use
withCredentials: trueand let the server read the session cookie. - URL parameters: Pass the token in the URL (less secure, be careful with logs):
new EventSource('/api/stream?token=abc123') - Use a polyfill or alternative library: Libraries like
eventsource(npm package) or custom implementations usingfetchwith readable streams allow custom headers.
Limitations of EventSource
Before choosing SSE, be aware of its limitations:
- One-way only: The client cannot send data through the SSE connection. Use separate HTTP requests for client-to-server communication.
- Text only: SSE only supports UTF-8 text. Binary data must be encoded (for example, Base64).
- No custom headers: You cannot set
Authorizationor other custom headers on theEventSourcerequest. - Connection limit: Browsers limit the number of concurrent SSE connections per domain. In HTTP/1.1, this is typically 6 connections per domain. HTTP/2 multiplexes over a single connection, effectively removing this limit.
- GET only:
EventSourcealways sends aGETrequest. NoPOSTor other methods.
The 6 connections per domain limit in HTTP/1.1 is a serious concern. If a user has your site open in multiple tabs, each tab opens its own SSE connection. With 6 tabs, you have exhausted the browser's connection pool for that domain, and other requests (API calls, resource loading) will be blocked. Always use HTTP/2 in production to avoid this issue, or use a shared connection via SharedWorker or BroadcastChannel.
SSE vs. WebSocket: When to Use Which
Both SSE and WebSocket enable real-time communication, but they serve different purposes. Choosing the right one depends on your use case.
Feature Comparison
| Feature | SSE (EventSource) | WebSocket |
|---|---|---|
| Direction | Server to client (one-way) | Bidirectional (two-way) |
| Protocol | HTTP | ws:// / wss:// (protocol upgrade) |
| Data format | Text (UTF-8) only | Text and binary |
| Auto-reconnection | Built-in | Must implement manually |
| Event IDs / Resume | Built-in | Must implement manually |
| Custom headers | Not supported | Supported during handshake |
| Browser support | All modern browsers | All modern browsers |
| HTTP/2 compatible | Yes, naturally | Separate connection |
| Proxy/firewall friendly | Yes (standard HTTP) | Can be blocked by some proxies |
| Max connections (HTTP/1.1) | 6 per domain | No such limit (separate protocol) |
| Complexity | Very simple | More complex |
When to Use SSE
SSE is the better choice when:
- Data flows in one direction (server to client). Examples:
- Live news feeds
- Stock price tickers
- Social media notifications
- Real-time dashboards and analytics
- Server log streaming
- Build/deployment status updates
- Live sports scores
- You want simplicity. SSE is significantly easier to set up and maintain.
- You need automatic reconnection and event resumption without writing extra code.
- Your infrastructure is HTTP-based and you do not want to deal with WebSocket proxies.
When to Use WebSocket
WebSocket is the better choice when:
- Bidirectional communication is required. Examples:
- Chat applications
- Multiplayer games
- Collaborative editing (Google Docs-style)
- Real-time trading platforms with user actions
- Binary data needs to be transmitted (images, audio, video chunks, protocol buffers).
- Low latency in both directions is critical (gaming, live audio/video).
- High-frequency messaging in both directions is needed.
When to Use Neither
For some use cases, you may not need either:
- Infrequent updates (every 30+ seconds): Simple polling with
setIntervalandfetchmay be sufficient and simpler. - One-time data retrieval: A regular
fetchrequest is all you need.
Quick Decision Guide
Do you need the server to push data to the client in real-time?
├── No → Use regular fetch/polling
└── Yes
├── Does the client also need to send frequent real-time data?
│ ├── Yes → Use WebSocket
│ └── No → Use SSE ✅
└── Do you need to send binary data?
├── Yes → Use WebSocket
└── No → Use SSE ✅
Practical Example: Live Notification System
Here is a complete, practical example of a live notification system using SSE.
Server (Node.js with Express)
const express = require('express');
const app = express();
// Store connected clients
const clients = new Set();
let notificationId = 0;
// SSE endpoint
app.get('/api/notifications', (req, res) => {
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive'
});
// Set reconnection delay
res.write('retry: 5000\n\n');
// Handle reconnection
const lastId = parseInt(req.headers['last-event-id'] || '0', 10);
if (lastId > 0) {
console.log(`Client reconnected from event ID ${lastId}`);
}
// Add client to the set
clients.add(res);
console.log(`Client connected. Total clients: ${clients.size}`);
// Send a welcome message
res.write(`event: connected\ndata: {"clients": ${clients.size}}\n\n`);
// Keep-alive comment every 30 seconds
const keepAlive = setInterval(() => {
res.write(': keep-alive\n\n');
}, 30000);
// Clean up on disconnect
req.on('close', () => {
clients.delete(res);
clearInterval(keepAlive);
console.log(`Client disconnected. Total clients: ${clients.size}`);
});
});
// Endpoint to trigger a notification (simulates an internal event)
app.post('/api/send-notification', express.json(), (req, res) => {
const { type, message } = req.body;
notificationId++;
const eventData = JSON.stringify({
id: notificationId,
type,
message,
timestamp: new Date().toISOString()
});
// Broadcast to all connected clients
for (const client of clients) {
client.write(`id: ${notificationId}\n`);
client.write(`event: ${type || 'notification'}\n`);
client.write(`data: ${eventData}\n\n`);
}
res.json({ sent: true, clients: clients.size });
});
app.listen(3000, () => console.log('Server running on port 3000'));
Client (Browser)
class NotificationStream {
constructor(url) {
this.url = url;
this.eventSource = null;
this.handlers = {};
}
connect() {
this.eventSource = new EventSource(this.url);
this.eventSource.onopen = () => {
console.log('[NotificationStream] Connected');
};
// Handle server-confirmed connection
this.eventSource.addEventListener('connected', (event) => {
const data = JSON.parse(event.data);
console.log(`[NotificationStream] ${data.clients} clients online`);
});
// Handle different notification types
this.eventSource.addEventListener('info', (event) => {
const data = JSON.parse(event.data);
this.showNotification('info', data);
});
this.eventSource.addEventListener('warning', (event) => {
const data = JSON.parse(event.data);
this.showNotification('warning', data);
});
this.eventSource.addEventListener('alert', (event) => {
const data = JSON.parse(event.data);
this.showNotification('alert', data);
});
// Generic notifications
this.eventSource.addEventListener('notification', (event) => {
const data = JSON.parse(event.data);
this.showNotification('default', data);
});
this.eventSource.onerror = () => {
if (this.eventSource.readyState === EventSource.CONNECTING) {
console.log('[NotificationStream] Reconnecting...');
} else {
console.log('[NotificationStream] Connection closed');
}
};
}
showNotification(level, data) {
console.log(`[${level.toUpperCase()}] ${data.message} (${data.timestamp})`);
// In a real app, you would update the DOM here
}
disconnect() {
if (this.eventSource) {
this.eventSource.close();
console.log('[NotificationStream] Disconnected');
}
}
}
// Usage
const notifications = new NotificationStream('/api/notifications');
notifications.connect();
// Disconnect when the user navigates away (optional, browser does this automatically)
window.addEventListener('beforeunload', () => {
notifications.disconnect();
});
Output when running:
[NotificationStream] Connected
[NotificationStream] 1 clients online
[INFO] Server update deployed successfully (2024-01-15T10:30:00.000Z)
[WARNING] High CPU usage detected (2024-01-15T10:30:15.000Z)
[NotificationStream] Reconnecting...
[NotificationStream] Connected
[NotificationStream] 1 clients online
[ALERT] Database connection pool exhausted (2024-01-15T10:31:00.000Z)
Using Fetch with ReadableStream as an SSE Alternative
If you need features that EventSource does not support (like custom headers or POST requests), you can implement SSE manually using fetch with a readable stream:
async function connectSSE(url, token) {
const response = await fetch(url, {
headers: {
'Authorization': `Bearer ${token}`,
'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 });
// Split on double newlines (SSE message separator)
const messages = buffer.split('\n\n');
buffer = messages.pop(); // Keep the incomplete last part in the buffer
for (const message of messages) {
if (message.trim() === '') continue;
// Parse SSE fields
let data = '';
let eventType = 'message';
for (const line of message.split('\n')) {
if (line.startsWith('data: ')) {
data += (data ? '\n' : '') + line.slice(6);
} else if (line.startsWith('event: ')) {
eventType = line.slice(7);
}
}
if (data) {
console.log(`Event: ${eventType}, Data: ${data}`);
}
}
}
}
connectSSE('/api/stream', 'my-auth-token');
This approach gives you full control over headers and request methods, but you lose automatic reconnection, event ID tracking, and the clean event-driven API of EventSource. You must implement those features yourself if you need them.
Summary
Server-Sent Events (SSE) provide a simple, efficient, and HTTP-native way to push real-time data from server to client. Here are the key takeaways:
EventSourceis the browser API for SSE. Creating a connection is as simple asnew EventSource(url).- SSE supports three built-in events:
open,message, anderror. Custom event types can be defined by the server using theevent:field. - Auto-reconnection is built in. The browser reconnects automatically after a configurable delay, and sends the
Last-Event-IDheader to help the server resume. - The SSE protocol is plain text over HTTP, using fields like
data:,event:,id:, andretry:. - Choose SSE over WebSocket when data flows one way (server to client), you want simplicity, and you do not need binary data. Choose WebSocket when you need bidirectional, high-frequency communication.
- For scenarios requiring custom headers, consider using
fetchwith readable streams as an alternative toEventSource.
SSE is often overlooked in favor of WebSocket, but for many real-world scenarios like notifications, live feeds, dashboards, and status updates, it is the simpler, more maintainable, and more infrastructure-friendly choice.