Server-Sent Events
- server-sent-events
- sse
- nestjs
- nodejs
- redis
- real-time
- websockets
- typescript
- backend-development
The previous article in this NestJS Production Guide series covered WebSockets and gateways: full duplex channels for cases where the client needs to send messages just as often as it receives them. Most real-time features in a SaaS product don't need that. They need the server to push updates to a browser that's just sitting there, and nothing more.
That's the gap Server-Sent Events fills. It's an older, simpler spec than WebSockets, built on plain HTTP, and it solves a narrower problem well: a live progress bar, a notification feed, a streaming AI response, a build log tailing in real time. If your feature is one-directional, SSE is usually the better default, and it's worth understanding why before reaching for a gateway out of habit.
This article is part of the NestJS Production Guide. It covers what SSE actually is at the protocol level, how to implement it cleanly in Nest, where it breaks down, and the production details that don't show up in the basic tutorials.
What SSE actually is
Server-Sent Events is a long-lived HTTP response that never closes. The server sets Content-Type: text/event-stream, keeps the connection open, and writes small text-formatted chunks to it over time. The browser's EventSource API (or a manual fetch with a stream reader) parses those chunks as they arrive and fires events as each one lands. There's no new protocol to implement, no handshake beyond a standard HTTP request, and no separate port or upgrade negotiation the way WebSockets require.
The wire format is deliberately minimal:
event: progress
data: {"percent": 42}
id: 17
event: progress
data: {"percent": 68}
id: 18
Each event is a block of field: value lines terminated by a blank line. data carries the payload, event names the event type so the client can listen for specific ones, and id lets the browser resume from the last event it saw if the connection drops. That resumption behavior is built into EventSource for free: if the network blips, the browser reconnects automatically and sends a Last-Event-ID header, so your server can pick up where it left off instead of forcing the client to guess what it missed.
The tradeoff for that simplicity is the one-way constraint. The client can't send data over the same channel; anything it needs to tell the server goes over a normal request. For a progress feed, a notification stream, or a chat where only one side is broadcasting, that's simply the shape of the problem, not a limitation. For something genuinely bidirectional like a collaborative editor, you're back to WebSockets.
Implementing SSE in NestJS
Nest has first-class support for this through the @Sse() decorator, which expects the handler to return an RxJS Observable of MessageEvent objects. The framework takes care of setting the response headers and writing each emitted value in the correct wire format.
// src/modules/jobs/jobs.controller.ts
import { Controller, Sse, Param, MessageEvent } from '@nestjs/common';
import { Observable } from 'rxjs';
import { JobsService } from './jobs.service';
@Controller('jobs')
export class JobsController {
constructor(private readonly jobs: JobsService) {}
@Sse(':id/progress')
progress(@Param('id') id: string): Observable<MessageEvent> {
return this.jobs.watchProgress(id);
}
}
The service side is where the real design decision lives: where does the Observable get its data from. For a single-process deployment, an in-memory Subject per job is enough:
// src/modules/jobs/jobs.service.ts
import { Injectable } from '@nestjs/common';
import { MessageEvent } from '@nestjs/common';
import { Observable, Subject } from 'rxjs';
import { map, finalize, takeWhile } from 'rxjs/operators';
interface JobProgress {
percent: number;
done: boolean;
}
@Injectable()
export class JobsService {
private readonly streams = new Map<string, Subject<JobProgress>>();
private getOrCreateStream(jobId: string): Subject<JobProgress> {
let stream = this.streams.get(jobId);
if (!stream) {
stream = new Subject<JobProgress>();
this.streams.set(jobId, stream);
}
return stream;
}
emitProgress(jobId: string, progress: JobProgress): void {
this.getOrCreateStream(jobId).next(progress);
if (progress.done) {
this.getOrCreateStream(jobId).complete();
this.streams.delete(jobId);
}
}
watchProgress(jobId: string): Observable<MessageEvent> {
return this.getOrCreateStream(jobId).asObservable().pipe(
map((progress) => ({ data: progress, type: 'progress' }) as MessageEvent),
takeWhile((event) => !(event.data as JobProgress).done, true),
finalize(() => this.streams.delete(jobId)),
);
}
}
Whatever produces the actual progress (a queue processor, a BullMQ worker, an external API poller) calls emitProgress as work happens, and every connected client watching that job ID gets pushed the update. The finalize operator matters here: it cleans up the map entry when the client disconnects (Nest unsubscribes the observable automatically when the HTTP connection closes), so you don't leak an entry per job that nobody's watching anymore.
Multi-instance deployments need a shared transport
The in-memory approach above works cleanly until you run more than one instance of the API behind a load balancer, which is the normal case for anything in production. If the job update arrives on instance A but the client watching that job is connected to instance B, the in-memory Subject on instance A never reaches instance B's response stream. The client just hangs at whatever percentage it last saw.
The fix is the same one you'd reach for with WebSockets: back the event stream with Redis pub/sub (or a message broker you already run) instead of a local Subject, so any instance can publish an update and any instance holding the relevant SSE connection can forward it.
// src/modules/jobs/jobs-redis.service.ts
import { Injectable, OnModuleDestroy } from '@nestjs/common';
import { MessageEvent } from '@nestjs/common';
import { Observable } from 'rxjs';
import Redis from 'ioredis';
@Injectable()
export class JobsRedisService implements OnModuleDestroy {
private readonly publisher = new Redis(process.env.REDIS_URL);
private readonly subscriber = new Redis(process.env.REDIS_URL);
publishProgress(jobId: string, payload: unknown): Promise<number> {
return this.publisher.publish(`job:${jobId}`, JSON.stringify(payload));
}
watchProgress(jobId: string): Observable<MessageEvent> {
const channel = `job:${jobId}`;
return new Observable<MessageEvent>((subscriber) => {
const handler = (chan: string, message: string) => {
if (chan === channel) {
subscriber.next({ data: JSON.parse(message), type: 'progress' });
}
};
this.subscriber.subscribe(channel);
this.subscriber.on('message', handler);
return () => {
this.subscriber.off('message', handler);
this.subscriber.unsubscribe(channel);
};
});
}
async onModuleDestroy() {
await this.publisher.quit();
await this.subscriber.quit();
}
}
Note the separate Redis connections for publishing and subscribing. This isn't paranoia: ioredis (and Redis clients generally) puts a connection into a dedicated subscriber mode once you call subscribe, and it can no longer run normal commands on that same connection. Using one client for both roles is a common mistake that works in a quick test and then breaks the first time you need to publish and subscribe concurrently.
Reach for this only once you're actually running multiple instances or expect to soon. A single-instance deployment doesn't need Redis in the loop at all, and adding it earlier than the problem exists is the kind of premature infrastructure this series generally argues against.
Reconnection, backpressure, and proxy pitfalls
A few details separate a demo from something that survives real network conditions:
Heartbeats. Idle SSE connections get killed by load balancers, corporate proxies, and browsers themselves after a period of silence, often well under a minute. Send a comment line (a line starting with :) every 15 to 30 seconds to keep the connection alive even when there's no real data to push:
import { interval } from 'rxjs';
import { map } from 'rxjs/operators';
const heartbeat$ = interval(20000).pipe(map(() => ({ type: 'ping', data: {} }) as MessageEvent));
Merge this with your real event stream so the connection never goes fully silent.
Buffering by intermediary proxies. Nginx, and some CDNs, buffer response bodies by default, which defeats streaming entirely: the client gets nothing until the buffer fills or the connection closes. If you're behind Nginx, set proxy_buffering off (or the equivalent per-location directive) for the SSE route specifically, not globally, since disabling buffering everywhere has its own cost on regular responses.
Reverse proxy timeouts. A default proxy_read_timeout of 60 seconds will silently kill a long SSE connection if the heartbeat interval is too long relative to it. Set the timeout for the SSE route explicitly, and keep the heartbeat interval comfortably under it.
Client-side reconnection isn't automatically resumption-safe. EventSource reconnects on its own, but your server needs to honor Last-Event-ID if resuming mid-stream matters for the feature. For progress bars and notification toasts, restarting the stream from "now" on reconnect is fine and simpler than replaying missed events. Save the replay logic for features that genuinely can't tolerate missing one, like an audit-style activity feed.
Connection limits per origin. Browsers cap concurrent HTTP connections per origin (historically six for HTTP/1.1), and an open EventSource counts against that budget for as long as it's connected. If a page opens several SSE streams at once, it can starve other requests to the same origin. HTTP/2 removes this ceiling because it multiplexes streams over one connection, which is one more reason to make sure your production stack is actually serving over HTTP/2 if you're leaning on SSE for more than one feed per page.
When SSE is the wrong tool
SSE earns its keep for server-to-client push where the client's only job is to listen: progress indicators, live dashboards, streaming LLM completions, log tailing, notification badges. It rides on plain HTTP, which means it works through existing infrastructure, authentication middleware, and load balancers without special-casing, and debugging it is just reading a response stream in the browser's network tab.
It stops being the right tool the moment the client needs to send frequent messages back on the same logical channel: chat, presence, collaborative cursors, anything where both sides are talking. A WebSocket gateway collapses that into one channel more naturally than pairing SSE with a separate request cycle for the other direction. It's also worth a second look for binary payloads; SSE is text-only by spec, so binary data has to be base64-encoded first, which WebSockets handle natively.
The practical rule for this series holds here too. Reach for SSE by default for one-way push, since it's less infrastructure and easier to reason about, and upgrade to WebSockets only when a feature genuinely needs two-way, low-latency communication that a request/response pattern can't express cleanly.
Key takeaways
- SSE is a long-lived HTTP response, not a new protocol, so it works with existing auth, proxies, and load balancers without special handling.
- Nest's `@Sse()` decorator expects an `Observable<MessageEvent>`; the framework handles headers and wire formatting for you.
- An in-memory `Subject` is fine for a single instance; move to Redis pub/sub (or your existing broker) once you run more than one instance, not before.
- Heartbeats, proxy buffering settings, and reverse proxy timeouts are the details that separate a working demo from a connection that survives real traffic.
- Reserve replay-on-reconnect logic for features that genuinely can't tolerate missing an event; restarting the stream from "now" is enough for most cases.
- Choose SSE by default for one-directional push and reach for WebSockets only when the client needs to send data just as often as it receives it.
Frequently asked questions
Does SSE work over HTTP/2, and does it help?
Yes, and it helps meaningfully. HTTP/2 multiplexes multiple streams over a single TCP connection, which removes the browser's per-origin connection cap that otherwise limits how many concurrent SSE streams a page can hold open.
Can I send SSE events with binary data?
Not directly. The spec is text-only, so binary payloads need to be base64-encoded or otherwise converted to text before sending, which adds overhead. If binary data is a core part of the feature, a WebSocket is the better fit.
How is SSE different from long polling?
Long polling opens a request, waits for data or a timeout, returns, and immediately opens another request, repeating the cycle. SSE opens one connection and keeps it open indefinitely, with the server writing to it as events occur. SSE avoids the connection churn and latency of repeatedly reestablishing HTTP requests.
Do I need Redis for SSE if I only run one server instance?
No. A single instance can hold all its SSE connections and the state driving them in memory. Add Redis pub/sub only when you scale to multiple instances and a client's connection might be on a different instance than the one producing the update.
Why does my SSE connection keep dropping every 30 to 60 seconds?
Usually a proxy or load balancer timing out an idle connection. Add a periodic heartbeat comment to keep traffic flowing, and check your reverse proxy's read timeout and buffering settings, since both commonly interfere with long-lived streaming responses.
Can I use SSE for authenticated endpoints?
Yes, since it's a normal HTTP request, existing auth guards and middleware apply the same way they do to any other route. The one wrinkle is that the browser's native `EventSource` API doesn't support setting custom headers, so token-based auth for SSE usually goes through a cookie or a short-lived token in the URL rather than an `Authorization` header.
Further reading
Explore more on

About the author
I'm Aman Kumar Singh, a software engineer in Noida, India building scalable full-stack products with React, Next.js, Node.js, NestJS, PostgreSQL, Redis, and AWS. I write about backend engineering, distributed systems, and system design.