# NestJS SSE in Practice: Streaming Job Progress with @Sse and RxJS

Progress bars are where "real-time" usually starts in a backend. A user kicks off something slow, such as a download, an export or an AI job, and wants to watch it finish. In NestJS the cleanest tool for that is Server-Sent Events: one `@Sse()` route, one RxJS `Observable`, and the browser's `EventSource` on the other end.

I run this in production in [Snagbox](https://github.com/fabwaseem/snagbox), my open-source NestJS downloader API. For this post I rebuilt its SSE layer in a small test app on NestJS 12.1.3 and Node.js 22.20, drove it from headless Chrome 155, and measured what each design choice actually does. Below are the patterns I'd keep, the ones I changed, and the numbers behind them.

## TL;DR: NestJS SSE in production

Return an `Observable<MessageEvent>` from an `@Sse()` route. Then:

- Give each job its own `ReplaySubject(1)`, so a client that connects mid-job gets the latest progress at once. In my test that took 2 to 4 ms, against 451 to 1,002 ms with a plain `Subject`.
- Set `id` on every message yourself. Nest numbers messages per connection, so after a reconnect the ids start at 1 again and `Last-Event-ID` is useless.
- Close the `EventSource` on the client when the job finishes. When your Observable completes, the browser treats it as a dropped connection and reconnects, and it replayed my stream three times in 1.6 seconds.
- Send a heartbeat as a comment frame (`{ comment: "ping" }`). The client never sees it, and Nest gives it no id.
- Don't open one stream per item in a batch. Over HTTP/1.1, Chrome opened 6 of 8 streams to my server, and an ordinary `fetch()` to the same host was still waiting 5 seconds later. Poll one batch endpoint instead, or serve over HTTP/2.

| Result (measured on the box) | Value |
| --- | --- |
| One 10 s job, 40 updates, SSE | 1 request, 40 of 40 updates, 4,516 bytes |
| Same job, polling every 1 s | 11 requests, 10 of 40 updates, 4,438 bytes |
| Same job, polling every 250 ms | 41 requests, 40 of 40 updates, 16,506 bytes |
| SSE delivery lag on localhost | 0.8 ms mean, 2 ms max |
| Late joiner, `ReplaySubject(1)` vs `Subject` | 2 to 4 ms vs 451 to 1,002 ms to first frame |
| Server memory per open stream | about 34 to 64 KB of RSS (5,000 and 1,000 streams) |
| Chrome 155, HTTP/1.1, 8 `EventSource`s | 6 opened, the 7th, 8th and a `fetch()` stalled |

## What are Server-Sent Events?

SSE is a plain HTTP response with `Content-Type: text/event-stream` that never ends until one side closes it. The server writes UTF-8 frames separated by a blank line. Each frame can carry `data:`, an `event:` name, an `id:` and a `retry:` delay, and a line starting with `:` is a comment. The format and the reconnection rules are in the [WHATWG HTML spec](https://html.spec.whatwg.org/multipage/server-sent-events.html).

In the browser, [`EventSource`](https://developer.mozilla.org/en-US/docs/Web/API/EventSource) does all the client work. It parses frames, fires events, remembers the last id, and reconnects after a dropped connection, sending that id back in a `Last-Event-ID` header.

SSE only goes from the server to the client. For job progress, notifications, build logs and streamed LLM tokens, that is all you need. The client can still send commands through normal POST requests.

## SSE vs WebSockets vs polling

| | SSE | WebSockets | Polling |
| --- | --- | --- | --- |
| Direction | Server to client | Both ways | Client asks |
| Protocol | Plain HTTP | Upgrade to `ws://` | Plain HTTP |
| Reconnects | Built into `EventSource` | You write it | Not needed |
| Works through HTTP proxies and CDNs | Usually, if buffering is off | Needs upgrade support | Yes |
| Custom request headers from the browser | No | No | Yes |
| Nest setup | `@Sse()` in a controller | A gateway and an adapter | A `@Get()` route |

Use WebSockets when the client also streams data back at a high rate, for example in chat or collaborative editing. Use polling when updates are rare or when you track many items at once (more on that below). For one long job and one watcher, SSE is the best fit.

## How to use @Sse in NestJS

The smallest working endpoint returns an Observable. Nest writes every emitted value as a frame and ends the response when the Observable completes.

```ts
import { Controller, MessageEvent, Sse } from "@nestjs/common";
import { Observable, interval, map, take } from "rxjs";

@Controller("demo")
export class DemoController {
  @Sse("ticks")
  ticks(): Observable<MessageEvent> {
    return interval(1000).pipe(
      take(5),
      map((i) => ({ data: { tick: i } })),
    );
  }
}
```

Objects in `data` are serialized as JSON. The `MessageEvent` interface in `@nestjs/common` 12.1.3 has five optional fields: `data`, `id`, `type`, `retry` and `comment`. The full list of features is in the [NestJS SSE docs](https://docs.nestjs.com/techniques/server-sent-events).

I checked the response headers with `curl -D -`. Nest 12.1.3 sends `Content-Type: text/event-stream`, `Cache-Control: private, no-cache, no-store, must-revalidate, max-age=0, no-transform`, `Connection: keep-alive` and `X-Accel-Buffering: no`. The last one tells Nginx not to buffer the stream, which is the most common reason SSE "works locally but not in production". You don't have to set it yourself.

## Streaming job progress: one ReplaySubject per job

A real job runs in a worker, not in the controller. The worker needs somewhere to publish progress, and the SSE route needs somewhere to subscribe. In Snagbox that is a small service holding one RxJS subject per job ([source](https://github.com/fabwaseem/snagbox/blob/main/src/downloads/job-events.service.ts)). This is a trimmed version:

```ts
import { Injectable } from "@nestjs/common";
import { Observable, ReplaySubject } from "rxjs";

export type JobEvent =
  | { type: "progress"; seq: number; percentage: number }
  | { type: "complete"; seq: number; url: string }
  | { type: "error"; seq: number; message: string };

@Injectable()
export class JobEventsService {
  private readonly channels = new Map<string, ReplaySubject<JobEvent>>();

  private channel(jobId: string): ReplaySubject<JobEvent> {
    let subject = this.channels.get(jobId);
    if (!subject) {
      // Buffer of 1: a client that joins mid-job gets the latest frame at once.
      subject = new ReplaySubject<JobEvent>(1);
      this.channels.set(jobId, subject);
    }
    return subject;
  }

  emit(jobId: string, event: JobEvent): void {
    this.channel(jobId).next(event);
  }

  finish(jobId: string, event: JobEvent): void {
    const subject = this.channel(jobId);
    subject.next(event);
    subject.complete();
    // Late subscribers still get the terminal frame for a while, then we free it.
    setTimeout(() => this.channels.delete(jobId), 5 * 60_000).unref();
  }

  stream(jobId: string): Observable<JobEvent> {
    return this.channel(jobId).asObservable();
  }
}
```

Why `ReplaySubject(1)` and not `Subject`? Users don't open the progress page at the exact moment a job starts. They refresh, switch tabs, or open it from a notification. A plain `Subject` only delivers future values, so a late client sees an empty progress bar until the next update arrives.

I measured this. A job emitted progress every 2 seconds, and a client connected between two updates, 5 seconds in, five times. With `ReplaySubject(1)` the first frame arrived in 2 to 4 ms. With `Subject` it took 451 to 1,002 ms, which is simply the wait for the next emit. With slower jobs, such as a big download that reports every few seconds, that wait gets longer.

Keep the buffer at 1. Progress is a state, not a log, so the latest frame is all a new client needs, and a bigger buffer only replays stale percentages.

This map lives in one process. If you run several API instances, the worker's events have to reach whichever instance holds the client's connection. Put a broker in between, such as Redis pub/sub, and feed each instance's subjects from it.

## The production SSE controller

Here is the route, close to Snagbox's [downloads controller](https://github.com/fabwaseem/snagbox/blob/main/src/downloads/downloads.controller.ts) but with three changes I made after testing:

```ts
import { Controller, MessageEvent, NotFoundException, Param, Sse } from "@nestjs/common";
import { Observable, endWith, ignoreElements, interval, map, merge, of, share, takeUntil } from "rxjs";
import { JobEventsService, type JobEvent } from "./job-events.service.js";
import { JobStore } from "./job-store.js";

const HEARTBEAT_MS = 15_000;
const toMessage = (event: JobEvent): MessageEvent => ({ id: String(event.seq), data: event });

@Controller("jobs")
export class JobsController {
  constructor(
    private readonly events: JobEventsService,
    private readonly store: JobStore,
  ) {}

  @Sse(":id/events")
  stream(@Param("id") id: string): Observable<MessageEvent> {
    const job = this.store.get(id);
    if (!job) throw new NotFoundException(`No job ${id}`);

    // Finished before the client connected: send the terminal frame and stop.
    if (job.last && job.last.type !== "progress") return of(toMessage(job.last));

    const events$ = this.events.stream(id).pipe(map(toMessage), share());
    const done$ = events$.pipe(ignoreElements(), endWith(null));
    // A comment frame (": ping") keeps proxies from reaping the idle socket.
    const heartbeat$ = interval(HEARTBEAT_MS).pipe(
      map((): MessageEvent => ({ comment: "ping" })),
      takeUntil(done$),
    );
    return merge(events$, heartbeat$);
  }
}
```

`JobStore` is wherever you keep job state. Snagbox keeps its download records in Postgres through Prisma. I ran this exact controller and checked each branch with curl:

- An unknown id returns a normal `404` JSON response. Throwing before you return the Observable works, because Nest hasn't sent the stream headers yet. Under the spec, a non-200 response makes `EventSource` give up rather than retry, which is what you want for a job that doesn't exist.
- A finished job returns one `complete` frame and ends.
- A running job streams frames with `id: 6`, `id: 7` and so on, matching the job's own sequence number, with `: ping` comments in between.

### Why set the id yourself

If a message has no `id`, Nest 12.1.3 adds one: a counter that starts at 1 on each connection. I tested what that means for reconnects in headless Chrome. The browser reconnected and sent `Last-Event-ID: 3`, and the new connection numbered its frames from 1 again. The header is there, but it no longer means anything.

Using the job's sequence number as the id fixes that. On reconnect, read the header with `@Headers("last-event-id")` and skip events the client has already seen. For progress, the `ReplaySubject` already sends the latest state, so you can often ignore the header and still be correct.

### The heartbeat: a comment, not a data frame

Snagbox merges a heartbeat into the stream every 15 seconds. The comment in its source says why: proxies reap idle connections, and the Next.js rewrite proxy in front of the API is one of them. `takeUntil(done$)` stops the timer when the job ends, so the heartbeat never holds a finished stream open.

Snagbox sends the heartbeat as `data: {"type":"ping"}` and every client has to ignore it. On Nest 12.1.3 you can return `{ comment: "ping" }` instead. Nest writes it as `: ping`, gives it no id, and `EventSource` doesn't fire any event for it. I confirmed both in the curl output and in Chrome.

The `share()` matters too. Both `merge` and `done$` subscribe to `events$`, and `share()` makes them use one subscription to the job channel instead of two.

## The client: close the EventSource when the job ends

This is the mistake I'd most expect to reach production. When the job finishes, the Observable completes and Nest ends the response. To `EventSource`, an ended response is a dropped connection, so it waits for the retry delay and connects again.

I tested this with an endpoint that sent three events and completed, with `retry: 300`. In 1.6 seconds Chrome received the same three events three times, plus the first event of a fourth run. For a progress bar that means flicker and wasted requests. For "show a toast when the download is ready", it means the toast fires again and again.

The fix is on the client: treat the terminal event as the end.

```ts
type JobEvent =
  | { type: "progress"; seq: number; percentage: number }
  | { type: "complete"; seq: number; url: string }
  | { type: "error"; seq: number; message: string };

export function watchJob(jobId: string, onEvent: (event: JobEvent) => void) {
  const source = new EventSource(`/api/jobs/${jobId}/events`, { withCredentials: true });

  source.onmessage = (message) => {
    const event = JSON.parse(message.data) as JobEvent;
    onEvent(event);
    if (event.type === "complete" || event.type === "error") source.close();
  };

  // Network blips are retried by the browser. A 404 or 401 closes the stream.
  source.onerror = () => {
    if (source.readyState === EventSource.CLOSED) onEvent({ type: "error", seq: -1, message: "stream closed" });
  };

  return () => source.close();
}
```

Two more things I checked in Chrome:

- **Named events skip `onmessage`.** If you set `type: "progress"` on the Nest `MessageEvent`, the frame goes out as `event: progress`, and only `addEventListener("progress", ...)` receives it. In my test, `onmessage` got the unnamed frame and missed the named one. Either put the type inside `data`, as above, or add a listener for every event name you send.
- **`EventSource` can't send custom headers.** The constructor takes a URL and a `withCredentials` flag, nothing else. If your API uses an `X-API-Key` or a bearer token, use a cookie session for the browser, a short-lived signed token in the query string, or `fetch()` with a streamed response body and your own frame parser.

## Don't open one stream per item: the six-connection limit

Over HTTP/1.1, browsers allow only six connections per host, and each open `EventSource` holds one for as long as it lives. Snagbox's controller has a comment about this: batch downloads poll `GET /v2/downloads?ids=...` instead of opening one stream per job, because "a 50-item batch would stall on its own event streams".

I measured how bad it is. A page in headless Chrome 155 opened 8 `EventSource`s to my local Nest server over plain HTTP/1.1. Six opened within 5 ms. The other two never opened during the 3-second window. Then the page made an ordinary `fetch()` to the same server, and it was still pending after 5 seconds. Six progress streams can freeze the rest of your app's API calls on that host.

You have three ways out:

1. **Serve over HTTP/2.** All requests share one connection, and the limit becomes the number of concurrent streams the server allows. [MDN's EventSource page](https://developer.mozilla.org/en-US/docs/Web/API/EventSource) notes that this defaults to 100. If a proxy or CDN in front of your API already speaks HTTP/2 to the browser, you may have this for free.
2. **Multiplex.** Open one stream per user or per batch and include the job id in every event.
3. **Poll a batch endpoint for many items.** This is what Snagbox does for playlists:

```ts
@Get()
getMany(@Query("ids") ids = "") {
  return ids
    .split(",")
    .map((id) => id.trim())
    .filter(Boolean)
    .slice(0, 100)
    .map((id) => this.store.get(id) ?? { id, status: "unknown" });
}
```

One request every second or two for a 50-item batch costs far less than 50 streams, and it can't block anything.

## SSE vs polling: what one job costs

I ran one simulated job that lasted 10 seconds and emitted 40 progress updates, one every 250 ms. A Node.js client watched it three ways, and I counted the bytes from the socket itself (`socket.bytesRead` and `socket.bytesWritten`), so HTTP headers are included. The client and server ran on the same box, over localhost, with the Express adapter.

![Bar chart of bytes on the wire for one 10-second job with 40 updates: SSE 4,516 bytes in 1 request, polling every 1 s 4,438 bytes in 11 requests, polling every 250 ms 16,506 bytes in 41 requests](https://res.cloudinary.com/wasicloudinary/image/upload/v1792387499/fabwaseem/blog/nestjs-server-sent-events/diagram-sse-vs-polling.jpg)

| Transport | Requests | Updates seen | Bytes down | Bytes up | Total |
| --- | --- | --- | --- | --- | --- |
| SSE, one stream | 1 | 40 of 40 | 4,437 | 79 | 4,516 |
| Polling every 1 s | 11 | 10 of 40 | 3,525 | 913 | 4,438 |
| Polling every 250 ms | 41 | 40 of 40 | 13,144 | 3,362 | 16,506 |

Polling once a second used about the same bytes as SSE but showed only a quarter of the updates, so the bar moves in jumps and can show a value up to a second old. Polling fast enough to see every update took 41 requests and 3.7 times the bytes. SSE delivered each update 0.8 ms after the server emitted it on average, with a 2 ms maximum.

Each SSE update cost about 110 bytes, which is mostly the JSON. Each polled response carried around 320 bytes, because every one repeats the HTTP headers. With API-key auth, a rate limiter and logging on every request, the request count matters more than the bytes.

## How much memory does an open SSE stream use?

An open stream is a socket, a response object and an RxJS subscription. I opened 1,000 and then 5,000 more concurrent streams against the same job and read the server's resident memory from `/proc/<pid>/status`. The client ran in a separate process.

| Open streams | Server RSS before | RSS after | Per stream |
| --- | --- | --- | --- |
| 1,000 | 75.8 MB | 138.0 MB | about 64 KB |
| 5,000 | 138.7 MB | 305.8 MB | about 34 KB |

The per-stream figure fell at the larger count, probably because the first batch also paid one-off costs as the heap grew (RSS didn't shrink between the runs). Treat it as a rough range for Nest on Express and Node 22, not a constant. It does show that one instance can hold thousands of idle progress streams. Your file-descriptor limit (`ulimit -n`) and proxy timeouts are more likely to stop you first.

I also counted live subscriptions in the service. With 5,000 streams open, the count was 5,000. 1.5 seconds after the clients disconnected, it was 0. Nest unsubscribes from your Observable when the client goes away, so the RxJS teardown runs and the subject doesn't leak subscribers.

If your handler does async setup before it returns the Observable, such as opening a database cursor or an LLM session, Nest 12.1.3 also has an `@SseSignal()` parameter decorator. It injects an `AbortSignal` that fires when the stream ends, so you can release setup resources even if the client left before the Observable existed.

## A smooth progress bar: never let it go backwards

One Snagbox detail isn't about SSE but shows up in every progress UI. Snagbox runs yt-dlp, which downloads video and audio as two separate passes and reports each from 0 to 100%. Forwarded as-is, the bar fills, snaps back to zero and fills again.

Snagbox's [`ProgressAggregator`](https://github.com/fabwaseem/snagbox/blob/main/src/engine/progress.ts) fixes this. When downloaded bytes drop, it banks the finished pass. It keeps the percentage monotonic and caps it at 99.9 while the process is alive, because the last pass finishing isn't the job finishing, since the merge still has to run. Only the `complete` event reports 100%. If your job has more than one phase, do the same before anything reaches the stream.

## How I tested

- Box: Linux, 8 vCPUs, shared with other workloads. Node.js 22.20.0, `@nestjs/core` 12.1.3 on `@nestjs/platform-express` 12.1.3, RxJS 7.8.2, TypeScript 5.9, compiled to ESM.
- Jobs were simulated with `setInterval`, so the tests measure the transport and Nest, not a real download.
- Bytes came from the client socket's counters, with keep-alive for the polling client.
- The late-joiner test ran five times per subject type, joining 5.0 to 5.5 s into a job that emitted every 2 s.
- Browser tests used `puppeteer-core` driving Chrome 155 headless, against `http://127.0.0.1` over HTTP/1.1.
- Memory is VmRSS of the Nest process, read before opening streams and 4 seconds after the last one opened.

These are single-machine, localhost numbers. Over a real network, polling gets worse, because each request pays the round trip, while SSE pays it once.

## Common mistakes with NestJS SSE

- **Not closing the `EventSource` on the terminal event.** The browser reconnects and replays the job.
- **Relying on Nest's automatic ids.** They restart at 1 on every connection. Use your own sequence.
- **Sending `type` on the `MessageEvent` and listening with `onmessage`.** Named events only reach `addEventListener`.
- **Using a plain `Subject`.** Late clients wait for the next update. Use `ReplaySubject(1)`.
- **One stream per list item over HTTP/1.1.** Six streams block the host. Poll a batch endpoint or use HTTP/2.
- **No heartbeat behind a proxy.** Idle streams get cut. Send a comment frame every 15 to 30 seconds.
- **Putting the API key in a header the browser can't send.** `EventSource` has no headers option.
- **Keeping job channels forever.** Delete them a few minutes after the terminal event, as Snagbox does.

For the queue side of long-running work, read [Building an AI automation pipeline in Node.js with BullMQ](https://www.fabwaseem.com/blog/ai-automation-pipeline-nodejs-bullmq). If you're on an older Nest version, [the NestJS 12 migration guide](https://www.fabwaseem.com/blog/nestjs-12) covers the upgrade. For a secure API base to put this in, see [my NestJS + Prisma API starter](https://www.fabwaseem.com/blog/secure-nestjs-prisma-api-starter).

## Key takeaways

- NestJS streams SSE from any `@Sse()` route that returns an Observable, and sets `text/event-stream`, no-cache and `X-Accel-Buffering: no` for you.
- A `ReplaySubject(1)` per job gave late clients their first frame in 2 to 4 ms, against up to 1,002 ms with a plain `Subject`.
- Set `id` from your job's sequence number. Nest's automatic ids restart at 1 on every reconnect.
- Close the `EventSource` on the terminal event. A completed stream made Chrome reconnect and replay it three times in 1.6 s.
- Send heartbeats as `{ comment: "ping" }`, which the browser ignores and Nest leaves without an id.
- Over HTTP/1.1, Chrome opened 6 of 8 streams and then stalled a normal `fetch()`. Use HTTP/2, one multiplexed stream, or a batch polling endpoint.
- For one 10-second job, SSE used 1 request and 4,516 bytes. Polling every 250 ms needed 41 requests and 16,506 bytes for the same updates.


---

*Originally published on [fabwaseem.com](https://www.fabwaseem.com/blog/nestjs-server-sent-events).*
