skip to content

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