JavaScript Server-Sent Events (EventSource)
Learn JavaScript Server-Sent Events with the EventSource API — receive a one-way stream of updates from the server over HTTP.
Server-Sent Events (SSE) are a standard way for a server to push a continuous, one-way stream of text updates to the browser over a single, long-lived HTTP connection. Instead of the client repeatedly polling an endpoint with fetch to ask "is there anything new?", the connection stays open and the server writes messages whenever it has something to send. The browser exposes this through the built-in EventSource interface, so you do not need any external library to consume a stream.
SSE shines when updates flow in one direction — from server to client. Live news feeds, notifications, build or upload progress bars, monitoring dashboards, and stock tickers are all natural fits.
Receiving a stream with EventSource
Opening a stream takes a single line. You create an EventSource pointed at a server endpoint, then attach handlers for the events it fires.
const es = new EventSource('/events');
es.onmessage = (event) => {
console.log('New message:', event.data);
};
es.onopen = () => {
console.log('Connection opened');
};
es.onerror = (error) => {
console.log('Connection error:', error);
};A few things are worth noting:
- The connection opens as soon as you construct the
EventSource. onmessagefires for every default (unnamed) message the server sends. The payload is always a string, available onevent.data.onopenfires once when the connection is established (and again after a reconnect).onerrorfires when the connection drops or fails. Unlike a failedfetch, this does not necessarily mean you should give up — the browser will usually try to reconnect on its own (more on that below).
Here is a slightly more complete example that parses JSON payloads and updates the page:
const es = new EventSource('/notifications');
es.onmessage = (event) => {
const data = JSON.parse(event.data);
const li = document.createElement('li');
li.textContent = data.message;
document.querySelector('#feed').append(li);
};The wire format
The server replies with the text/event-stream content type and writes plain UTF-8 text in a simple line-based format. Each message is a block of field: value lines, and a blank line marks the end of one message.
data: Hello there
data: First line
data: Second line
event: priceUpdate
data: {"symbol":"ACME","price":42.10}
id: 42
data: This message has an id
retry: 10000
data: Reconnect after 10 seconds next timeThe recognized fields are:
data:— the message payload. If a message has multipledata:lines, they are joined with a newline (\n) before being delivered asevent.data.event:— an optional event name. Without it, the message is delivered toonmessage.id:— an optional identifier the browser remembers for reconnection.retry:— the reconnection delay in milliseconds.
Lines starting with a colon (:) are comments and are ignored — servers often send them as keep-alive pings.
Named events
By default every message goes to onmessage. A server can instead label a message with an event: field, and the client listens for that specific name with addEventListener. This lets you route different kinds of updates to different handlers over the same connection.
Given a stream like this:
event: priceUpdate
data: {"symbol":"ACME","price":42.10}
event: newsAlert
data: Markets closed early today
data: a plain default messageYou would wire up the client like so:
const es = new EventSource('/market');
es.addEventListener('priceUpdate', (event) => {
const { symbol, price } = JSON.parse(event.data);
console.log(`${symbol} is now ${price}`);
});
es.addEventListener('newsAlert', (event) => {
console.log('News:', event.data);
});
// Messages with no `event:` field still land here.
es.onmessage = (event) => {
console.log('Default message:', event.data);
};Automatic reconnection
This is the feature that sets SSE apart from rolling your own streaming with fetch. If the connection drops — a flaky network, a server restart, a proxy timeout — the browser reconnects automatically after a short delay. You do not have to write any retry loop.
To make resuming reliable, the browser keeps track of the last id: it received. On reconnect it sends that value back in a Last-Event-ID request header, so the server can pick up exactly where the stream left off rather than replaying everything.
GET /events HTTP/1.1
Last-Event-ID: 42
Accept: text/event-streamThe server can also tune the delay before the next reconnect by sending a retry: field (in milliseconds):
retry: 5000
data: The browser will wait 5 seconds before reconnecting if droppedAutomatic reconnection only happens while the connection is considered recoverable. If the server responds to a reconnect with an HTTP error such as 204 No Content or a 4xx status, the browser treats the stream as finished and stops trying.
Connection state and closing
An EventSource exposes its current state through the readyState property, which matches three constants:
EventSource.CONNECTING(0) — connecting, or waiting to reconnect.EventSource.OPEN(1) — connected and receiving.EventSource.CLOSED(2) — closed and will not reconnect.
When you no longer need the stream, call es.close(). This shuts the connection down immediately, and — importantly — the browser will not reconnect afterward.
const es = new EventSource('/events');
// Stop listening after 30 seconds.
setTimeout(() => {
es.close();
console.log('readyState is now', es.readyState); // 2 (CLOSED)
}, 30000);Cross-origin requests and credentials
By default an EventSource follows the same-origin policy. To stream from a different origin, the server must send the appropriate CORS headers (such as Access-Control-Allow-Origin).
Cookies are not sent on cross-origin requests unless you opt in. Pass the withCredentials option, and make sure the server allows credentials in its CORS configuration:
const es = new EventSource('https://api.example.com/events', {
withCredentials: true,
});A note on the server side
SSE is mostly a browser feature, but the server has to cooperate. Whatever language you use, the recipe is the same: respond with Content-Type: text/event-stream, keep the connection open instead of ending the response, and write event-formatted chunks as data becomes available.
Here is a minimal Node.js example to show the shape of it:
import http from 'node:http';
http.createServer((req, res) => {
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
});
let id = 0;
const timer = setInterval(() => {
id += 1;
res.write(`id: ${id}\n`);
res.write(`data: The time is ${new Date().toISOString()}\n\n`);
}, 1000);
// Stop the timer when the client disconnects.
req.on('close', () => clearInterval(timer));
}).listen(3000);Each chunk ends with a blank line (\n\n) to complete the message. The client connected with new EventSource('http://localhost:3000') would receive one update per second.
Limitations
SSE carries text only (UTF-8) — there is no binary frame type, so you cannot stream raw bytes such as images or audio without encoding them first. It is also strictly one-directional: to send data to the server you still need a normal request via fetch or XMLHttpRequest, or a full-duplex WebSocket connection.
One more practical limit: over HTTP/1.1, browsers cap the number of simultaneous connections to a single origin at around six. Because each EventSource holds one connection open, opening many tabs to the same site can exhaust that budget. This is far less of a concern over HTTP/2, where many streams are multiplexed onto one connection.
SSE vs. WebSockets
SSE and WebSockets both deliver real-time data, but they solve different problems.
| Server-Sent Events | WebSockets | |
|---|---|---|
| Direction | One-way (server → client) | Two-way (full duplex) |
| Protocol | Plain HTTP | ws:// / wss:// upgrade |
| Data | Text (UTF-8) only | Text and binary |
| Reconnection | Automatic, built in | Implement it yourself |
| Best for | Feeds, notifications, progress | Chat, games, collaborative editing |
Reach for SSE when the browser only needs to listen — notifications, live feeds, dashboards, progress updates. Its automatic reconnection, plain-HTTP transport, and tiny client API make it the simplest tool for the job. Reach for WebSockets when the client also needs to send frequently, when you need binary data, or when latency per message matters, as in chat apps and multiplayer games.
If your data already lives behind a promise-based async flow, pairing SSE handlers with async/await for any follow-up requests (for example, fetching full details when a lightweight notification arrives) keeps your code clean and readable.