skip to content

Consuming Server-Sent Events

You will learn what it takes to wire an EventSource into a real app: named-event listeners, reconnect behavior you must design around, connection limits, and teardown. Interviewers ask when a candidate reaches for WebSockets on a one-way feed.

on this pageshow

questions

5

A server-sent events stream in the browser delivers messages tagged with an event name such as `priceUpdate`, but the page's `eventSource.onmessage` handler never fires. Why does that happen, and how do you receive those messages?

level: juniorimportance: must knowfreq 60%

answer

  1. EventSource is an EventTarget
  2. onmessage is not a catch-all
  3. event type equals the server's name
  4. one addEventListener per event name
  5. data is a string, parse it

basics

~10 s

EventSource dispatches a named message as an event of that name, so onmessage never sees it. Register eventSource.addEventListener('priceUpdate', handler) instead, and expect event.data as a string you parse yourself.

solid answer

~40 s

An `EventSource` is an `EventTarget`, and it dispatches every incoming message as an event whose type is the name the server gave that message. Only messages the server leaves unnamed arrive as type `message`, and `onmessage` is just a shorthand for a listener on that one type — so a stream where every message is named looks completely silent through `onmessage`. The fix is `es.addEventListener('priceUpdate', handler)`, one listener per name you care about. Whichever handler receives it, the event is a `MessageEvent`: `event.data` is always a **string**, so `JSON.parse` it yourself; `event.lastEventId` carries the id the server attached, and `event.origin` lets you check where it came from. The same object also fires `open` and `error`, and you end the stream with `es.close()`.

code

javascript · 17 lines
javascript
const es = new EventSource('/api/prices');

// Named messages never reach onmessage.
es.addEventListener('priceUpdate', (event) => {
  const payload = JSON.parse(event.data);
  console.log(payload.symbol, payload.price, event.lastEventId);
});

// Only messages the server left unnamed arrive here.
es.addEventListener('message', (event) => {
  console.log('unnamed:', event.data);
});

es.addEventListener('open', () => console.log('stream open'));
es.addEventListener('error', () => console.log('readyState', es.readyState));

window.addEventListener('pagehide', () => es.close());

go deeper

for a junior

Know that a named message is dispatched as an event of that name, so you register addEventListener with the name and cannot rely on onmessage. Say plainly that event.data is a string you parse yourself.

for a middle

Explain that EventSource is an EventTarget and onmessage is only a shorthand for the unnamed message type, then cover MessageEvent's data, lastEventId and origin, plus why close() is the only thing that ends the stream.

for a senior

Show that you design the event-name contract deliberately: one generic name with a discriminator in the payload versus many names, defensive JSON parsing so one bad message cannot break the handler, and a guaranteed teardown path.

for a principal

Own the client-server contract itself — who may add event names, how the payload schema versions, and whether the feed should carry many typed events or one envelope. Argue the maintenance cost of each on both sides.

## What EventSource hands you `new EventSource('/api/prices')` opens one long-lived HTTP GET request and turns the response the server keeps writing into a stream of DOM events fired on the `EventSource` object itself. That object is an `EventTarget`, so nothing new needs to be learned about listening: `addEventListener`, `removeEventListener` and the usual handler properties all work exactly as they do on a DOM element. The object fires three built-in events. `open` fires once the connection is established. `error` fires when the connection drops or fails. And messages arrive as their own events — which is where the confusion starts. ## Why onmessage misses named messages A message on a server-sent events stream may carry an event name. The browser dispatches such a message as an event **whose `type` is that name**. A message with no name is dispatched with the type `message`. `onmessage` is nothing more than a convenience property for a listener on type `message`. It is not a catch-all. If the server names every message it sends — `priceUpdate`, `heartbeat`, `orderFilled` — then no event of type `message` is ever dispatched and `onmessage` stays silent forever, even though bytes are flowing and the Network panel shows a healthy open request. So the rule is simple: **you must know the event names the server uses**, and register one listener per name. ```js const es = new EventSource('/api/prices'); es.addEventListener('priceUpdate', handlePrice); es.addEventListener('heartbeat', () => lastSeen = Date.now()); ``` There is no wildcard listener. If the server may introduce new names, either agree on a single generic name and put the discriminator inside the payload, or agree that the contract lists every name up front. Teams usually pick the first: one named event carrying `{ "type": "..." }` inside the data is easier to evolve than a growing list of listener registrations. ## What the event object contains Every message — named or not — is delivered as a `MessageEvent`, and three of its properties matter: - `event.data` is **always a string**. It is never parsed for you, never an object, never a `Blob` or `ArrayBuffer`. Server-sent events is a text transport; if you are shipping JSON you call `JSON.parse(event.data)` yourself, and you wrap that call in a `try/catch` if a malformed frame would otherwise take down your handler. - `event.lastEventId` is the id the server attached to that message, or an empty string if it attached none. It is useful for de-duplication when you keep your own record of what you have already applied. - `event.origin` is the origin the stream came from. On a cross-origin stream it is worth asserting against an expected value rather than trusting the payload blindly. `event.type` is, of course, the event name — handy if several names share one handler function. ## Lifecycle around the listeners Two more things belong in the same block of code as your listeners. First, **read `es.readyState`** inside the `error` handler rather than assuming the stream died: `EventSource.CONNECTING` (0) means the browser is retrying on its own, `EventSource.OPEN` (1) means it is live, `EventSource.CLOSED` (2) means it has stopped for good. Second, **call `es.close()`** when you are done. An `EventSource` does not stop because the variable went out of scope, because you removed the listeners, or because the user navigated to another route in a single-page app. It holds the connection and re-establishes it after drops until something calls `close()`. ```js const es = new EventSource('/api/prices'); es.addEventListener('priceUpdate', onPrice); // later, in whatever cleanup path your framework gives you: es.close(); ``` ## The mistakes that actually get made Assuming `onmessage` is a catch-all is the first. Assuming `event.data` is already an object is the second — a handler that reads `event.data.price` silently yields `undefined` rather than throwing, which makes the bug oddly hard to spot. The third is registering the listener on a *different* `EventSource` than the one that is connected, which happens when a render function constructs a new stream on every pass. The fourth is forgetting that the stream is one-way: the browser's `EventSource` gives you no way to send anything back on it, so anything the client needs to say goes out as an ordinary request.

  • If the server may add new event names later, how would you design the client so it does not need a code change each time?
    Agree on one event name for the feed and put the discriminator inside the payload — `{ "kind": "priceUpdate", ... }` — then a single listener parses `event.data` and dispatches on `kind`. `EventSource` has no wildcard listener, so a growing list of names means a client release for every server addition. Reserve separate names for genuinely separate concerns, like a heartbeat you want to ignore cheaply.
  • What type is `event.data`, and what breaks if you treat it as an object?
    It is always a string. Reading `event.data.price` gives `undefined` rather than throwing, so the bug shows up as blank UI rather than an error in the console. Call `JSON.parse(event.data)` inside a `try/catch`, because one malformed message would otherwise throw inside your handler and can leave the rest of the update path unrun.
  • Can you send data back to the server over the same EventSource?
    No. `EventSource` is strictly one-way: the browser opens a GET and only reads. Anything the client needs to send goes out as a separate request — typically `fetch` to a normal endpoint — and the server's response comes back down the stream if it needs to be broadcast. If you find yourself sending constantly, that is the signal to reconsider the transport.

Think of the stream as post arriving in labelled pigeonholes: onmessage is only the unlabelled-post slot, so if the sender labels every envelope you have to open the pigeonhole with that label on it.

saying these in an interview costs you the question

  • Claiming onmessage receives every message on the stream
  • Saying event.data arrives already JSON-parsed
  • Thinking removing listeners closes the connection
  • Expecting to send messages back through the EventSource
  • Assuming a wildcard listener exists for all event names

context

open as a page

Your page's EventSource keeps firing its `error` event and the Network panel shows the request being re-issued, yet nothing in your code reopened the stream. What is EventSource doing, and when does it stop retrying on its own?

level: middleimportance: must knowfreq 58%

basics

~20 s

EventSource reconnects by itself after a dropped connection, firing error each time. Check readyState: CONNECTING means it will retry, CLOSED means it gave up. It gives up on close(), or when the response is not 200 with Content-Type text/event-stream.

open as a page

You need to open an EventSource in the browser against an API on another origin and have the request authenticated. What can and cannot the EventSource constructor do about request headers and credentials, and what has to be true on the server?

level: middleimportance: should knowfreq 45%

basics

~20 s

EventSource accepts only a URL and { withCredentials }. It cannot set an Authorization header, cannot change the method from GET, and sends no body. Cross-origin credentialed streams need the server to allow that exact origin and credentials.

open as a page

In a single-page app that opens an EventSource when a dashboard route mounts, users report that after moving between routes for a while the page stops loading anything and new requests sit pending forever. How would you diagnose this, and what is the fix?

level: seniorimportance: should knowfreq 40%

basics

~20 s

Each route visit opens a stream that nothing closes, and an EventSource stays open and self-reconnects until close() is called. Over HTTP/1.1 the leaked streams occupy the browser's roughly six connections per origin, so every other request queues.

open as a page

You are designing a one-way live feed of order-status updates for a web app, and the team's instinct is to reach for WebSockets. How would you decide between server-sent events, WebSockets, and periodic polling?

level: principalimportance: should knowfreq 42%

basics

~20 s

Match the transport to the traffic shape. A one-way feed suits server-sent events, which ride ordinary HTTP and reconnect themselves; WebSockets earn their extra machinery only with real client-to-server chat; polling wins when updates are rare and latency tolerance is loose.

open as a page