There are scenarios where you want to push data one-way from server to client — job progress notifications, LLM streaming responses, and so on. When “WebSocket feels like overkill,” Server-Sent Events (SSE) becomes a great middle-ground option.
In this article, we’ll cover implementations using both Spring MVC’s SseEmitter and WebFlux’s Flux<ServerSentEvent>, the client-side reception, and the operational pitfalls you’re likely to hit.
What Are Server-Sent Events (SSE)?
SSE is a mechanism that keeps a single HTTP/1.1 connection open and pushes data one-way from server to client. The Content-Type is text/event-stream, and the browser’s EventSource API supports it out of the box.
Compared to WebSocket, the nice points are:
- It’s plain HTTP, so existing infrastructure for authentication and proxies works as-is
- The browser handles reconnection automatically
- The
Last-Event-IDheader lets you tell the server “where to resume from”
On the flip side, if you need to send something from client to server, you’ll need to call a regular REST API. If you need full bidirectional communication, see Implementing real-time communication with WebSocket in Spring Boot.
Using SseEmitter in Spring MVC
Let’s start with the minimal Spring MVC implementation. Just specify text/event-stream for produces and return SseEmitter.
@RestController
@RequestMapping("/api/progress")
public class ProgressController {
private final ExecutorService executor = Executors.newCachedThreadPool();
@GetMapping(value = "/{jobId}", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter stream(@PathVariable String jobId) {
SseEmitter emitter = new SseEmitter(Duration.ofMinutes(10).toMillis());
executor.execute(() -> {
try {
for (int i = 1; i <= 100; i++) {
emitter.send(SseEmitter.event()
.id(String.valueOf(i))
.name("progress")
.reconnectTime(3000)
.data(Map.of("jobId", jobId, "percent", i)));
Thread.sleep(500);
}
emitter.complete();
} catch (Exception e) {
emitter.completeWithError(e);
}
});
return emitter;
}
}
The key point is offloading the sending to a separate thread. The basic pattern is: return the controller thread quickly, hold onto the SseEmitter reference, and keep calling send() in the background.
The SseEmitter.event() builder lets you specify id, name, reconnectTime, and data. The id is important information used for Last-Event-ID resumption (covered later), so always include it if you want resumable delivery.
You can set the timeout via the constructor argument or globally via spring.mvc.async.request-timeout in application.properties. The default tends to cut off unexpectedly, so it’s recommended to set it explicitly based on your use case.
Using Flux<ServerSentEvent> with Spring WebFlux
In the reactive stack, it’s even more straightforward. Just return Flux<ServerSentEvent<T>>.
@RestController
@RequestMapping("/api/stocks")
public class StockController {
@GetMapping(value = "/{symbol}", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<ServerSentEvent<StockPrice>> stream(@PathVariable String symbol) {
return Flux.interval(Duration.ofSeconds(1))
.map(seq -> ServerSentEvent.<StockPrice>builder()
.id(String.valueOf(seq))
.event("price")
.retry(Duration.ofSeconds(3))
.data(fetchPrice(symbol))
.build());
}
}
Because WebFlux uses an event-loop model, the big advantage is that it can handle thousands to tens of thousands of concurrent connections with very few threads. This pairs especially well with use cases like SSE that hold connections open for long periods. For the basics of reactive, see Introduction to reactive programming with Spring WebFlux.
By the way, if you pass a non-string object as data, it’ll be automatically serialized to JSON.
Receiving on the Client Side (EventSource)
On the browser side, EventSource gets it done in just a few lines.
const es = new EventSource('/api/progress/job-123');
es.addEventListener('progress', (event) => {
const payload = JSON.parse(event.data);
console.log(`${payload.percent}%`);
});
es.addEventListener('done', () => {
es.close();
});
es.onerror = (err) => {
console.warn('disconnected, will auto-reconnect', err);
};
When the connection drops, EventSource automatically attempts to reconnect. At that time, it sends the id of the last event it received in the Last-Event-ID header, so the server can use that ID to deliver only the missing events.
@GetMapping(value = "/{jobId}", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter stream(
@PathVariable String jobId,
@RequestHeader(value = "Last-Event-ID", required = false) String lastEventId) {
int startFrom = lastEventId == null ? 0 : Integer.parseInt(lastEventId);
// Send only events after startFrom
...
}
Note that EventSource has a limitation: it can’t send custom headers (like Authorization). Cookie-based auth is easy if that works for you, but if you want to use JWT, you’ll need to parse it yourself with fetch + ReadableStream, or consider a polyfill.
Timeouts and Heartbeats
In long-lived connections, the biggest enemy is “a stretch of time where nothing flows.” Proxies and load balancers can decide a connection is “too idle” and cut it off.
The countermeasure is simple: periodically send a comment line. Comments are lines starting with :, which clients ignore, but the TCP connection stays alive.
@Scheduled(fixedRate = 15000)
public void heartbeat() {
emitters.forEach(emitter -> {
try {
emitter.send(SseEmitter.event().comment("ping"));
} catch (IOException e) {
emitter.complete();
}
});
}
MVC’s SseEmitter occupies one async servlet thread per connection, so you need to watch server.tomcat.threads.max and the connection limit. If you’re anticipating thousands of simultaneous connections, it’s safer to choose WebFlux from the start.
Reverse Proxy Settings That Trip You Up
When you put Nginx in front of things in production, a common accident is that the default proxy_buffering is on, and events only arrive in bulk. Always disable it for SSE locations.
location /api/stream/ {
proxy_pass http://backend;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 1h;
add_header X-Accel-Buffering no;
}
If you also return X-Accel-Buffering: no from the server, Nginx will disable buffering for that specific response.
Another surprising pitfall is gzip compression. When data piles up in the compression buffer, events get delayed, so it’s safer to exclude SSE endpoints from gzip.
When to Use SSE vs. WebSocket
Here’s a table of criteria for when you’re torn between the two.
| Aspect | SSE | WebSocket |
|---|---|---|
| Direction | One-way: server → client | Bidirectional |
| Protocol | HTTP/1.1 (text/event-stream) | Custom protocol (Upgrade) |
| Auto-reconnect | Supported natively by browser | Must implement yourself |
| Proxy/Auth | Easy since it’s plain HTTP | Often needs special configuration |
| Implementation cost | Low | Somewhat high |
| Good use cases | Progress notifications, LLM streaming, dashboards | Chat, collaborative editing, games |
Roughly speaking, “if the client doesn’t need to actively send anything, first consider whether SSE is enough” is the right order of thinking.
Where SSE Shines for LLM Streaming Responses
A typical recent example is relaying streaming responses from an LLM. The pattern is: receive tokens streaming from an OpenAI-compatible API with WebClient, and forward them downstream as SSE.
@GetMapping(value = "/chat", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<ServerSentEvent<String>> chat(@RequestParam String prompt) {
return llmClient.streamCompletion(prompt)
.map(token -> ServerSentEvent.<String>builder()
.event("token")
.data(token)
.build())
.concatWith(Mono.just(ServerSentEvent.<String>builder()
.event("done")
.data("[DONE]")
.build()));
}
The standard practice is to signal completion with a custom event like event: done and have the client call es.close(). For how to use WebClient, reading Choosing between RestTemplate and WebClient in Spring Boot alongside this should make the picture clearer.
Summary
SSE is an option that lets you implement “just steadily pushing information from server to client” use cases with far less effort than WebSocket.
- For Spring MVC, return
SseEmitter; for WebFlux, returnFlux<ServerSentEvent> - Reconnection works through the combination of
EventSourceandLast-Event-ID - Operational prep — heartbeats, Nginx buffering, gzip — is the key to success
- The moment you need bidirectional communication, switch to WebSocket
Start by trying it on small use cases like progress notifications or streaming responses. You’ll feel the reassurance of being able to handle it as a natural extension of HTTP.