Skip to main content

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 fetch or XMLHttpRequest).
  • 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:

  1. The client creates an EventSource object pointing to a server URL.
  2. The browser sends a standard GET request to that URL.
  3. The server responds with Content-Type: text/event-stream and keeps the connection open.
  4. The server sends data as plain text messages, formatted according to the SSE protocol.
  5. 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.

info

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:

ValueConstantMeaning
0EventSource.CONNECTINGConnecting (or reconnecting)
1EventSource.OPENConnection is open and active
2EventSource.CLOSEDConnection 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.

warning

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:

PropertyDescription
event.dataThe message payload (always a string)
event.lastEventIdThe last event ID set by the server (string)
event.originThe 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)
tip

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);
};
caution

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

FieldDescription
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

  1. The connection drops (server closes it, network error, etc.).
  2. The browser fires an error event.
  3. The browser waits for a retry delay (default is typically a few seconds, browser-dependent).
  4. The browser sends a new GET request to the same URL.
  5. If the server had sent an id: field, the browser includes a Last-Event-ID HTTP 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();
};
tip

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
});
caution

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: true and 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 using fetch with readable streams allow custom headers.

Limitations of EventSource

Before choosing SSE, be aware of its limitations:

  1. One-way only: The client cannot send data through the SSE connection. Use separate HTTP requests for client-to-server communication.
  2. Text only: SSE only supports UTF-8 text. Binary data must be encoded (for example, Base64).
  3. No custom headers: You cannot set Authorization or other custom headers on the EventSource request.
  4. 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.
  5. GET only: EventSource always sends a GET request. No POST or other methods.
warning

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

FeatureSSE (EventSource)WebSocket
DirectionServer to client (one-way)Bidirectional (two-way)
ProtocolHTTPws:// / wss:// (protocol upgrade)
Data formatText (UTF-8) onlyText and binary
Auto-reconnectionBuilt-inMust implement manually
Event IDs / ResumeBuilt-inMust implement manually
Custom headersNot supportedSupported during handshake
Browser supportAll modern browsersAll modern browsers
HTTP/2 compatibleYes, naturallySeparate connection
Proxy/firewall friendlyYes (standard HTTP)Can be blocked by some proxies
Max connections (HTTP/1.1)6 per domainNo such limit (separate protocol)
ComplexityVery simpleMore 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 setInterval and fetch may be sufficient and simpler.
  • One-time data retrieval: A regular fetch request 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');
note

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:

  • EventSource is the browser API for SSE. Creating a connection is as simple as new EventSource(url).
  • SSE supports three built-in events: open, message, and error. Custom event types can be defined by the server using the event: field.
  • Auto-reconnection is built in. The browser reconnects automatically after a configurable delay, and sends the Last-Event-ID header to help the server resume.
  • The SSE protocol is plain text over HTTP, using fields like data:, event:, id:, and retry:.
  • 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 fetch with readable streams as an alternative to EventSource.

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.