Server-Sent EventsTechniques · NestJS

Push adoptions to clients as they happen: a replaying stream of events in the service, an @Sse() route that merges it with a heartbeat, and the text/event-stream wire format Nest writes for you.

What you will learn

Read the theory for Server-Sent Events

All Techniques lessons

All NestJS courses

loading types…

What you'll learn

  • Write an @Sse() handler that returns an Observable<MessageEvent>, and read the event-stream lines it produces
  • Merge domain events from a subject with a periodic heartbeat, and explain what happens when the client disconnects
  • Choose ReplaySubject, BehaviorSubject or Subject by what a late subscriber should see

Server-Sent Events

A shelter's website shows the cats, and when one is adopted the page should say so, now, not after the visitor refreshes. HTTP as you have used it so far cannot: the server only speaks when asked. Server-sent events are the simplest way round that. The client opens one ordinary GET request and the server keeps it open, writing a message down it whenever it has one, for as long as both sides like. No new protocol, no handshake, no library on the client beyond EventSource, which every browser ships. WebSockets go both ways and cost more; for "tell me when something happens", SSE is enough.

The wire

The response has Content-Type: text/event-stream and never ends. Each message is a few lines of text followed by a blank line:

id: 3
event: adopted
data: {"id":1,"name":"Tom"}

data is the payload, event an optional type the client can listen for by name, id lets a reconnecting client say where it left off, retry tells it how long to wait before reconnecting. Nest writes these lines for you.

@Sse

A handler decorated with @Sse() returns an Observable, and every value it emits becomes a message:

@Sse('sse')
sse(): Observable<MessageEvent> {
  return interval(1000).pipe(map((_) => ({ data: { hello: 'world' } })));
}

MessageEvent is Nest's shape for one message: data (a string or an object, serialised as JSON), and optionally type, id and retry, which become the event, id and retry lines. The docs' example ticks every second forever; interval never completes, and neither does the response. A real stream is usually two things merged: the events that matter, from an rxjs Subject that some service calls next() on, and a heartbeat, a periodic message that keeps proxies from closing an idle connection and lets the client tell "quiet" from "dead". Merge them with merge(events$, interval(15000).pipe(map(...))).

When the client disconnects, Nest unsubscribes from the observable, so an interval stops ticking and a finalize() operator in the pipe runs cleanup. Nest 12 also offers @SseSignal(), an AbortSignal for the response's lifetime, for handlers that set something up asynchronously before returning the stream.

Late arrivals

A plain Subject gives a subscriber only what happens after it subscribed: a visitor who opens the page a second after an adoption never hears of it. ReplaySubject(n) remembers the last n values and replays them to each new subscriber, which is what a "recent adoptions" feed wants. BehaviorSubject holds one current value; a plain Subject forgets everything. Choose by what a late subscriber should see.

The client side is three lines:

const eventSource = new EventSource('/sse');
eventSource.onmessage = ({ data }) => {
  console.log('New message', JSON.parse(data));
};

onmessage receives messages without an event line; a named type is heard with eventSource.addEventListener('adopted', ...).

Your task

The shelter announces adoptions live.

  1. CatsService keeps a stream of adoption events that replays the last three to a late listener, and announces on it after every successful adoption.
  2. GET /cats/events is a server-sent events route: every adoption as a message of type adopted carrying the cat, merged with a heartbeat of type heartbeat every 300 ms.

The request panel reads a stream for a moment and shows what arrived. Adopt a cat, then open /cats/events: the adoption comes first, replayed, then heartbeats.

When it fails

  • The request hangs and times out: the handler returns an Observable without @Sse(). Nest then waits for the observable to complete before answering, and an interval never does.
  • The response ends after one message: the observable completed; of(...) or a take(1) ended it. A live stream stays open.
  • No adopted message, only heartbeats: the service never called next(), or the subject is a plain Subject and the adoption happened before the client connected.
  • event: adopted is missing and the client's addEventListener('adopted') never fires: the message has no type.

Remember

  • @Sse() on a handler that returns Observable<MessageEvent>; each emission is one message, data plus optional type, id and retry.
  • Merge the events that matter with a heartbeat; the response stays open until the client leaves, and Nest unsubscribes then.
  • A ReplaySubject(n) gives late subscribers the last n events; a Subject gives them nothing.
  • The client is new EventSource(url), with onmessage for untyped messages and addEventListener(type) for typed ones.
Stuck? Show a hint

Service: private readonly announcements = new ReplaySubject<AdoptionEvent>(3); this.announcements.next({ type: 'adopted', cat: saved }) after the save; adoptions() returns this.announcements.asObservable(). Controller: @Sse('events') events(): Observable<MessageEvent> { return merge(this.catsService.adoptions().pipe(map((e) => ({ type: 'adopted', data: e.cat }) as MessageEvent)), interval(300).pipe(map((n) => ({ type: 'heartbeat', data: { n } }) as MessageEvent))); } with merge, interval and map from 'rxjs' and MessageEvent from '@nestjs/common'.