Server Sent Events

Sometimes you don’t need a full two-way channel. You just want the server to keep talking to you: new chat messages, live scores, a ticker of stock prices, progress on a long job. The data flows one way, from server to browser, and it never stops mid-page.

The Server-Sent Events specification gives you a built-in class for exactly that: EventSource. It opens a connection to the server and holds it open, receiving events as the server writes them.

Like WebSocket, the connection stays alive. But the two solve different problems, and the differences matter when you pick one.

WebSocket EventSource
Bi-directional: client and server both send messages One-directional: only the server sends
Binary and text data Text only
The WebSocket protocol (its own upgrade handshake) Plain HTTP

Put bluntly, EventSource can do less than WebSocket. So why reach for it?

Because less is often enough, and simpler is a feature. Plenty of applications never send anything back to the server over the live channel; they only listen. If all you do is stream a feed of updates down to the page, WebSocket is more machinery than the job calls for. On top of that, EventSource reconnects on its own — behavior you’d have to hand-write with WebSocket — and it rides on ordinary HTTP, so there’s no new protocol for your proxies, load balancers, or firewalls to learn.

WebSocket
browser
⇆ both ways
server
EventSource
browser
← server → browser (one way)
server
WebSocket carries traffic both ways over its own protocol. EventSource is a one-way stream over plain HTTP.

Getting messages

To start listening, create new EventSource(url):

let eventSource = new EventSource("/events/subscribe");

The browser connects to url and keeps the connection open, waiting for the server to write events into it.

On the other end, the server must respond with status 200 and the header Content-Type: text/event-stream. Then, instead of closing the response, it holds the connection open and writes messages into it in a specific line-based format:

data: Message 1

data: Message 2

data: Message 3
data: of two lines

The rules of that format are short:

  • The message text follows data:. The space after the colon is optional.
  • Messages are separated by a double line break, \n\n.
  • To put a real line break \n inside one message, send another data: line. The third message above is a single message spanning two lines.
data: Message 1
data: Message 2
data: Message 3
data: of two lines
↑ one message, two lines — joined by \n
A blank line ends a message. Consecutive data: lines join into one message with \n between them.

In real code, you rarely rely on multi-line data:. Complex payloads travel as JSON, where a line break is already encoded as \n inside the string. So a single data: line carries the whole thing:

data: {"user":"Maya","message":"First line\n Second line"}

That lets you treat one data: block as exactly one message, which keeps the client simple.

Every message the browser reads fires a message event. Its text is on event.data:

let eventSource = new EventSource("/events/subscribe");

eventSource.onmessage = function(event) {
  console.log("New message", event.data);
  // logs 3 times for the data stream above
};

// same thing with addEventListener:
// eventSource.addEventListener('message', ...)

To feel how that parsing works, edit the raw stream below and press Parse. Consecutive data: lines fold into one message joined by \n, blank lines split messages apart, and id: / event: become metadata rather than body text:

interactiveParse a raw event stream the way the browser does

Cross-origin requests

EventSource can connect to a different origin, the same as fetch and other networking APIs. Any URL works:

let source = new EventSource("https://another-site.com/events");

The remote server receives an Origin header and must answer with Access-Control-Allow-Origin for the connection to go through. Without that header, the browser blocks it.

By default the request carries no cookies or HTTP auth. To include them, pass the withCredentials option:

let source = new EventSource("https://another-site.com/events", {
  withCredentials: true
});

The cross-origin rules are the same ones covered in Fetch: Cross-Origin Requests, so the mental model you already have for CORS carries straight over.

Reconnection

When you create an EventSource, it connects. If that connection later drops, it reconnects on its own. You write no retry loop, no exponential backoff, no “is it dead?” check. That auto-recovery is one of the strongest reasons to choose it.

There’s a short pause between a drop and the next attempt — a few seconds by default. The server can suggest its own delay with a retry: field, given in milliseconds:

retry: 15000
data: Hello, I set the reconnection delay to 15 seconds

retry: can ride along with data, as above, or arrive on its own as a standalone message. The browser treats the number as a floor, not a hard rule. It waits at least that long. It may wait longer — for instance, if the operating system reports there’s no network right now, the browser can hold off until connectivity returns, then try.

connect
open, streaming
connection drops
wait retry ms
↶ back to connect
The default connect / drop / wait / reconnect cycle. retry: tunes the wait.

Two parties can end the cycle deliberately:

  • The server stops the browser from reconnecting by answering with HTTP status 204 No Content. The browser takes that as “we’re done” and doesn’t try again.
  • The browser closes from its side by calling eventSource.close():
let eventSource = new EventSource(...);

eventSource.close();

Reconnection also stops on a broken response. If the server replies with the wrong Content-Type, or with any status other than 301, 307, 200, or 204, the browser gives up: it fires an "error" event and does not reconnect.

Message id

Networks fail at awkward moments. When a connection breaks mid-stream, neither side can be sure which messages actually arrived. Did message 7 land before the drop, or not? Guessing means either losing a message or replaying one.

The fix is to tag each message with an id: field:

data: Message 1
id: 1

data: Message 2
id: 2

data: Message 3
data: of two lines
id: 3

When the browser reads a message carrying id:, it does two things:

  • Stores that value on eventSource.lastEventId.
  • On the next reconnection, sends it back to the server in a Last-Event-ID header.

That header is the recovery handshake. The server reads “the client last saw id 2,” and resends everything after id 2. No gaps, no duplicates.

server sends id: 2
lastEventId = “2”
⚡ connection drops
reconnect: Last-Event-ID: 2
server resends from 3
lastEventId is remembered on the client and replayed as Last-Event-ID so the server knows where to resume.

Connection status: readyState

An EventSource object tracks its own state on the readyState property, which is always one of three values:

EventSource.CONNECTING = 0; // connecting or reconnecting
EventSource.OPEN = 1;       // connected
EventSource.CLOSED = 2;     // connection closed
0 CONNECTING
1 OPEN
2 CLOSED
(drop: 1 → 0 → 1)
readyState moves through three values; a drop sends it back to CONNECTING, close() ends at CLOSED.

A newly created object starts at CONNECTING (0), and it returns to CONNECTING any time the link goes down and a reconnect is pending. Read this property whenever you need to know where the connection stands — for example, inside an error handler to tell “temporarily reconnecting” apart from “dead for good.”

Event types

Out of the box, an EventSource object fires three events:

  • message — a message arrived; its text is on event.data.
  • open — the connection just opened.
  • error — the connection couldn’t be established or was lost, for example the server returned HTTP 500.

The server isn’t limited to plain messages. It can name a custom event type with an event: field placed before the data::

event: join
data: Theo

data: Hello

event: leave
data: Theo

Here the first block is a join event, the middle block has no event: so it’s an ordinary message, and the last is a leave event.

Custom event names can’t be caught with onmessage — that handler only ever fires for the default message type. For named events you must register with addEventListener:

eventSource.addEventListener('join', event => {
  alert(`Joined ${event.data}`);
});

eventSource.addEventListener('message', event => {
  alert(`Said: ${event.data}`);
});

eventSource.addEventListener('leave', event => {
  alert(`Left ${event.data}`);
});

Full example

Below is a server that streams 1, 2, 3, then bye, and drops the connection. Watch the browser reconnect on its own afterward, with no client code arranging it.

Since a live server can’t run inside this page, the demo below simulates an EventSource: a mock server pushes 1, 2, 3, then bye, and then drops. Watch the status badge fall back to CONNECTING and the stream restart on its own. Press Close to call close() and end the cycle for good.

interactiveA self-reconnecting event stream (simulated)

Summary

EventSource opens a persistent connection for you and lets the server push messages down it over ordinary HTTP.

What you get built in:

  • Automatic reconnect, with a tunable retry delay set by the server.
  • Message ids for resuming cleanly — the last id is replayed in the Last-Event-ID header on reconnect.
  • Live connection state on the readyState property.

Those features are why EventSource stands in for WebSocket in many cases. WebSocket is lower-level and gives you none of this out of the box; you’d build reconnection and resume yourself. When the data only flows one way, EventSource is frequently all you need.

It works in every modern browser (not Internet Explorer).

The constructor:

let source = new EventSource(url, [credentials]);

The second argument accepts a single option, { withCredentials: true }, which sends credentials on cross-origin requests. The cross-origin security model is the same one used by fetch and the other networking methods.

Properties of an EventSource object

readyState

The current connection state: EventSource.CONNECTING (=0), EventSource.OPEN (=1), or EventSource.CLOSED (=2).

lastEventId

The last received id. The browser replays it in the Last-Event-ID header when it reconnects.

Methods

close()

Closes the connection for good.

Events

message

A message arrived; the text is on event.data.

open

The connection is established.

error

Fired on any error, both a lost connection (auto-reconnect will follow) and fatal failures. Check readyState to tell whether a reconnect is being attempted.

The server can assign a custom event name with event:. Handle those with addEventListener, never on<event>.

Server response format

The server sends messages separated by \n\n. Each message may carry these fields:

  • data: — the message body. Several data: lines in a row become one message, joined with \n.
  • id: — updates lastEventId, which is sent back in Last-Event-ID on reconnect.
  • retry: — recommended reconnect delay in milliseconds. There’s no way to set it from JavaScript.
  • event: — the event name; it must come before data:.

A message may include one or more of these fields in any order, though id: conventionally comes last.