Using Named Event Types in SSE Permalink to this section
Part of Understanding the Event Stream Format, under SSE Protocol Fundamentals & Architecture.
The event: field is the difference between one stream that carries an application’s whole real-time surface and a stream per feature that exhausts the browser’s connection budget. It is also the single most common cause of “the server is sending events but my handler never fires”.
Symptom & Developer Intent Permalink to this section
- The server clearly writes events — visible in
curl— butonmessagenever fires in the browser. - Adding an
event:line to an existing stream silently breaks every existing client. - Each feature opens its own
EventSource, and the application starts queueing unrelated requests. - A new event type added server-side causes errors in older client versions.
- Client code branches on a
typefield inside the JSON payload to decide what to do.
The intent is one stream carrying several kinds of update, with each kind routed to its own handler and new kinds addable without breaking anyone.
Root Cause Analysis Permalink to this section
The event stream parser dispatches each block to a listener chosen by the event: field. When the field is absent the type defaults to message, which is what onmessage and addEventListener('message', …) listen for. The moment the server sets event: price, that block is dispatched as a price event — and a client with only an onmessage handler receives nothing at all. No error is raised anywhere: the event was delivered correctly to zero registered listeners.
This makes adding a named type a breaking change for existing clients, which is the second symptom. It also explains why the fix is often mistakenly to remove the name: the correct fix is to register the listener.
The alternative pattern — one unnamed message type carrying a type field inside the JSON — works, but it puts routing logic in every consumer and means every listener parses every payload. Named events push that routing into the browser’s own dispatcher, which is both faster and simpler to extend.
Step-by-Step Resolution Permalink to this section
Step 1 — Name events on the server Permalink to this section
// One stream, several named event types.
function writeEvent(res, { id, type, data }) {
// Field order does not matter; the blank line is what dispatches.
res.write(`id: ${id}\n`);
if (type) res.write(`event: ${type}\n`); // omit for the default "message"
res.write(`data: ${JSON.stringify(data)}\n\n`);
}
writeEvent(res, { id: 4821, type: 'price', data: { symbol: 'EUR', value: 1.08 } });
writeEvent(res, { id: 4822, type: 'notification', data: { text: 'Order shipped' } });
writeEvent(res, { id: 4823, type: 'presence', data: { userId: 7, online: true } });
Names should be stable, lowercase and namespaced by domain rather than by producer — order.created outlives orders-service-event.
Step 2 — Register a listener per type on the client Permalink to this section
const es = new EventSource('/api/stream', { withCredentials: true });
es.addEventListener('price', (e) => onPrice(JSON.parse(e.data)));
es.addEventListener('notification', (e) => onNotification(JSON.parse(e.data)));
es.addEventListener('presence', (e) => onPresence(JSON.parse(e.data)));
// Still worth keeping: catches anything sent without an event field.
es.onmessage = (e) => console.debug('[sse] unnamed event', e.data);
Step 3 — Add a catch-all so unknown types are visible, not silent Permalink to this section
Because unhandled named events vanish without a trace, a small registry makes the gap observable.
const HANDLERS = {
price: onPrice,
notification: onNotification,
presence: onPresence,
};
function attach(es, handlers) {
for (const [type, fn] of Object.entries(handlers)) {
es.addEventListener(type, (e) => {
try { fn(JSON.parse(e.data), e); }
catch (err) { console.warn(`[sse] handler failed for ${type}`, err); }
});
}
// Anything the server sends that this client version does not know about.
es.addEventListener('unknown_probe', () => {}); // no-op; see step 4
}
attach(es, HANDLERS);
Step 4 — Version by adding types, never by changing them Permalink to this section
Old clients ignore names they do not know, so additive change is safe and mutation is not.
// Safe: a new type. Clients that do not listen simply ignore it.
writeEvent(res, { id, type: 'order.updated.v2', data: newShape });
// Unsafe: changing the payload shape of an existing type.
// Old clients still listen for it and will break on the new shape.
During a migration, emit both for a period and retire the old name once telemetry shows no clients consuming it.
Step 5 — Route subscriptions server-side so clients get only what they need Permalink to this section
// Let the client declare interest; do not send every type to every stream.
app.get('/api/stream', (req, res) => {
const wanted = new Set((req.query.types ?? '').split(',').filter(Boolean));
const send = (event) => {
if (wanted.size && !wanted.has(event.type)) return; // filter at the source
writeEvent(res, event);
};
subscribe(send);
req.on('close', () => unsubscribe(send));
});
Filtering server-side saves bandwidth and, more importantly, avoids waking a mobile radio for events the client will discard.
Validation & Monitoring Permalink to this section
# 1. Confirm the event names actually on the wire.
curl -N -s https://api.example.com/api/stream | grep '^event:' | sort | uniq -c
# 14 event: price
# 3 event: notification
# 2. Confirm a client subscribing to a subset receives only that subset.
curl -N -s 'https://api.example.com/api/stream?types=notification' | grep '^event:' | sort -u
# event: notification
In the browser, the DevTools EventStream panel shows a type column per event, which makes an unhandled name obvious at a glance — the event is listed, and nothing happens in the application.
A useful production signal is a count of delivered events by type, compared against a client-side count of handled events by type. A type that is sent frequently and handled by nobody is either a deployment mismatch or a feature nobody enabled, and both are worth knowing.
// Client: report event types this build does not know how to handle.
const known = new Set(Object.keys(HANDLERS));
es.addEventListener('message', (e) => {
if (e.type && !known.has(e.type)) beacon('sse_unknown_event_type', e.type);
});
Production Checklist Permalink to this section
Frequently Asked Questions Permalink to this section
Why did onmessage stop firing when I added an event field?
Because onmessage listens for the default type, message, and an event carrying event: price is dispatched as a price event instead. Nothing errors — the event reaches zero listeners. Register addEventListener('price', …), and keep an onmessage handler for anything still sent unnamed.
Named events or a type field inside the JSON payload?
Named events, in most cases. The browser's dispatcher does the routing, each handler receives only what it cares about, and adding a type does not touch existing consumers. A payload discriminator is preferable only when consumers genuinely need to see every event regardless of kind — an audit log or a debugging view.
Is there a limit on how many event types one stream can carry?
No protocol limit. The practical limits are client-side listener registration and the bandwidth of sending types a client does not want. Filter server-side by subscription so a client receives only its types, and the number of names becomes a documentation concern rather than a performance one.
Do named events change how Last-Event-ID resume works?
No. The last event ID is tracked per connection, not per event type, so an id on any named event updates the same cursor. That is what makes a single multiplexed stream resumable as a unit — and it is why fragmenting into several streams also fragments your resume bookkeeping.