Server-Sent Events (SSE)
Server-Sent Events (SSE) is a simple standard for streaming updates from server to client over a single long-lived HTTP response. Unlike WebSockets it is one-way (server → client) and runs over plain HTTP with the text/event-stream content type. That simplicity makes SSE ideal for notifications, live progress bars, log tailing, and dashboards — anything where the client only needs to receive. Browsers consume it with the built-in EventSource API, including automatic reconnection.
When SSE fits
SSE is the right tool when:
- Data flows only from server to client (the client never needs to push over the same channel).
- You want something lighter than WebSockets, working over ordinary HTTP/HTTPS and proxies.
- You want automatic reconnection and event IDs for free.
Typical uses: notification feeds, job progress, price tickers, live logs, AI token streaming.
SSE in Spring MVC — SseEmitter
In the servlet (MVC) stack, return an SseEmitter and push events from another thread:
@RestController
@RequestMapping("/api/sse")
public class ProgressController {
@GetMapping(value = "/progress", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter progress() {
SseEmitter emitter = new SseEmitter(Duration.ofMinutes(5).toMillis());
Executors.newSingleThreadExecutor().execute(() -> {
try {
for (int pct = 0; pct <= 100; pct += 20) {
emitter.send(SseEmitter.event()
.name("progress")
.id(String.valueOf(pct))
.data(Map.of("percent", pct)));
Thread.sleep(500);
}
emitter.complete(); // close the stream cleanly
} catch (Exception ex) {
emitter.completeWithError(ex);
}
});
return emitter;
}
}
The produces = text/event-stream declaration is what turns this into an SSE endpoint. Always complete() (or completeWithError) so the connection is released.
Wire output:
event:progress
id:0
data:{"percent":0}
event:progress
id:20
data:{"percent":20}
Tip: For multiple subscribers (e.g. broadcasting notifications), keep a thread-safe collection of
SseEmitters, registeronCompletion/onTimeoutcallbacks to remove dead ones, and loop over them tosend.
SSE in WebFlux — Flux
On the reactive stack, return a Flux<ServerSentEvent<T>> — no manual threads, fully non-blocking:
@RestController
@RequestMapping("/api/sse")
public class TickerController {
@GetMapping(value = "/prices", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<ServerSentEvent<BigDecimal>> prices() {
return Flux.interval(Duration.ofSeconds(1))
.map(seq -> ServerSentEvent.<BigDecimal>builder()
.id(String.valueOf(seq))
.event("price")
.data(randomPrice())
.build());
}
}
A bare Flux<T> with produces=text/event-stream also works (Spring wraps each element as a data: line), but ServerSentEvent lets you set id, event, retry, and comment. See Reactive REST and Mono & Flux.
Consuming SSE in the browser
The native EventSource API handles the connection and reconnection automatically:
const source = new EventSource('/api/sse/progress');
source.addEventListener('progress', (e) => {
const data = JSON.parse(e.data);
console.log(`progress: ${data.percent}%`);
});
source.onerror = () => console.log('disconnected — browser will retry');
addEventListener('progress', ...) matches the event:progress name; use source.onmessage for unnamed events.
Reconnection and event IDs
SSE reconnection is built into the protocol:
- If the connection drops, the browser reconnects automatically after a delay.
- It sends the last received
idback in aLast-Event-IDheader, so the server can resume from where it left off. - The server can suggest a retry delay by emitting a
retry:field (set.retry(Duration.ofSeconds(3))onServerSentEvent).
@GetMapping(value = "/feed", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<ServerSentEvent<String>> feed(
@RequestHeader(value = "Last-Event-ID", required = false) String lastId) {
long start = lastId == null ? 0 : Long.parseLong(lastId) + 1;
return eventsFrom(start); // resume after the last delivered id
}
SSE vs WebSockets
| Aspect | SSE | WebSockets |
|---|---|---|
| Direction | One-way (server → client) | Full-duplex |
| Protocol | Plain HTTP, text/event-stream | Upgraded ws:// / wss:// |
| Browser API | EventSource (built-in) | WebSocket + STOMP libs |
| Auto-reconnect | Yes, with Last-Event-ID | Manual |
| Data format | UTF-8 text only | Text or binary |
| Best for | Notifications, progress, feeds | Chat, games, collaboration |
| Complexity | Low | Higher |
Note: Choose SSE when the client only listens. Reach for WebSockets when the client also needs to send messages over the same channel, or when you need binary frames.
Pitfalls
- Servlet threads: in MVC each
SseEmitterholds a connection; on a thread-per-request server, many open streams can exhaust the pool. WebFlux avoids this. - Proxies / buffering: some reverse proxies buffer responses — disable buffering (e.g. nginx
X-Accel-Buffering: no) so events flush promptly. - Connection limits: browsers cap concurrent connections per origin over HTTP/1.1; HTTP/2 multiplexing removes this.
- Always close emitters and clean up dead subscribers to avoid leaks.