There are scenarios where you want to push data one-way from server to client, like job progress notifications or LLM streaming responses. When you think “WebSocket is overkill for this…”, Server-Sent Events (SSE) becomes a great fit.
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 pushes data one-way from server to client while keeping a single HTTP/1.1 connection alive. The Content-Type is text/event-stream, and browsers support it natively through the EventSource API.
The advantages compared to WebSocket are roughly these:
- Since it’s just regular HTTP, existing infrastructure like authentication and proxies works as-is
- Browsers handle reconnection automatically
- The
Last-Event-IDheader lets you tell the server “where to resume from”
On the flip side, if you want to send something from the client to the server, you’ll need to hit a regular REST API. If you need full bidirectional communication, check out Implementing Real-time Communication with WebSocket in Spring Boot instead.
Using SseEmitter in Spring MVC
Let’s start with a minimal implementation in Spring MVC. Just specify text/event-stream in 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 to return immediately from the controller thread, keep a reference to the SseEmitter, and call send() in the background.
The SseEmitter.event() builder lets you specify id, name, reconnectTime, and data. The id is important information used for the Last-Event-ID resumption described later, so if your stream supports resumption, always set it.
The timeout can be configured globally via the constructor argument or spring.mvc.async.request-timeout in application.properties. The default tends to disconnect unexpectedly, so it’s recommended to set it explicitly based on your use case.
Using Flux<ServerSentEvent> in Spring WebFlux
The reactive stack lets you write it more straightforwardly. 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());
}
}
Since WebFlux uses an event loop model, the big advantage is being able to handle thousands to tens of thousands of concurrent connections with just a few threads. It’s particularly well-suited to use cases like SSE that keep long-lived connections. For reactive basics, see Introduction to Reactive Programming with Spring WebFlux.
By the way, if you pass an object other than a string to data, it’ll be automatically serialized to JSON.
Receiving on the Client Side (EventSource)
On the browser side, you can do it in a few lines using EventSource.
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 tries to reconnect. When it does, it sends the id of the last event received in the Last-Event-ID header, so the server can deliver only the delta based on that ID.
@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 the limitation that it can’t send custom headers (like Authorization). If cookie-based authentication works for you, that’s the easy path; if you want to use JWT, you’ll need to either parse SSE yourself with fetch + ReadableStream, or consider a polyfill.
Timeouts and Heartbeats
Broadcasting to Multiple Clients and Lifecycle Management
In production, you often run into the scenario where it’s not “one request, one stream” but “multiple clients subscribing to the same topic receiving the same events.” The standard pattern is to manage SseEmitter instances in a thread-safe collection and reliably clean them up with onCompletion, onTimeout, and onError.
@Component
public class SsePubSub {
private final Map<String, SseEmitter> emitters = new ConcurrentHashMap<>();
public SseEmitter subscribe(String clientId) {
SseEmitter emitter = new SseEmitter(Duration.ofMinutes(30).toMillis());
emitters.put(clientId, emitter);
emitter.onCompletion(() -> emitters.remove(clientId));
emitter.onTimeout(() -> {
emitters.remove(clientId);
emitter.complete();
});
emitter.onError(e -> emitters.remove(clientId));
return emitter;
}
public void broadcast(String eventName, Object payload) {
emitters.forEach((id, emitter) -> {
try {
emitter.send(SseEmitter.event().name(eventName).data(payload));
} catch (IOException e) {
emitter.completeWithError(e);
}
});
}
}
If you don’t remove entries from the collection in one of onCompletion / onTimeout / onError, references for disconnected clients will pile up and lead directly to a memory leak. If you’re putting MVC’s SseEmitter into production, this is a must-implement. In WebFlux, broadcasting with Sinks.Many is the straightforward approach.
private final Sinks.Many<ServerSentEvent<String>> sink =
Sinks.many().multicast().onBackpressureBuffer();
@GetMapping(value = "/feed", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<ServerSentEvent<String>> feed() {
return sink.asFlux();
}
Integration with Spring Security and CORS
SSE endpoints are just plain HTTP GETs, so you can write authorization with Spring Security’s SecurityFilterChain as usual. Two things to note:
- Since
EventSourcecan’t send custom headers, JWT authentication that assumes the Authorization header essentially needs to be moved to cookie-based sessions. - When connecting from a different origin, you need CORS settings that allow
withCredentials.
@Bean
public SecurityFilterChain sseSecurity(HttpSecurity http) throws Exception {
http
.securityMatcher("/api/stream/**")
.authorizeHttpRequests(a -> a.anyRequest().authenticated())
.cors(Customizer.withDefaults())
.csrf(csrf -> csrf.disable());
return http.build();
}
On the client side, you can send cookies with new EventSource(url, { withCredentials: true }). For details on authentication, also see How to Implement Google Login (OAuth2) in Spring Boot.
Testing SSE Endpoints
Spring MVC’s SseEmitter can be verified via MockMvc’s asyncDispatch. For WebFlux, the standard approach is to receive text/event-stream as a stream with WebTestClient.
@Test
void streamsProgressEvents() {
webTestClient.get().uri("/api/stocks/AAPL")
.accept(MediaType.TEXT_EVENT_STREAM)
.exchange()
.expectStatus().isOk()
.returnResult(new ParameterizedTypeReference<ServerSentEvent<StockPrice>>() {})
.getResponseBody()
.take(3)
.as(StepVerifier::create)
.expectNextCount(3)
.thenCancel()
.verify();
}
The pattern of “verify only the first n events and disconnect” using take(n) and thenCancel() is practical. For MVC streams that send a completion event, splitting mvcResult.getResponse().getContentAsString() by \n\n in MockMvc for verification is also a handy approach.
On long-lived connections, “periods where nothing flows” are the biggest enemy. Proxies and load balancers may decide the connection is “too idle” and close it.
The countermeasure is simple: just send a comment line periodically. 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 be careful about server.tomcat.threads.max and connection limits. If you’re expecting thousands of concurrent connections or more, choosing WebFlux from the start is safer.
Reverse Proxy Settings That Often Trip People Up
When you put Nginx in front in production, a common accident is that the default proxy_buffering is on, so events only arrive in batches. Always disable it in the SSE location.
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 turn off buffering just for that response.
Another surprising pitfall is gzip compression. Events get delayed when they pile up in the compression buffer, so it’s safer to exclude SSE endpoints from gzip.
When to Use SSE vs WebSocket
Here’s a decision table for when you’re unsure.
| Aspect | SSE | WebSocket |
|---|---|---|
| Direction | One-way server→client | Bidirectional |
| Protocol | HTTP/1.1 (text/event-stream) | Custom protocol (Upgrade) |
| Auto-reconnect | Built into browsers | Need to implement yourself |
| Proxy/Auth | Easy since it’s plain HTTP | Often needs dedicated config |
| Implementation cost | Low | Somewhat high |
| Suitable use cases | Progress notifications, LLM streaming, dashboards | Chat, collaborative editing, games |
Roughly speaking, the right order is: “If the client isn’t actively sending something, first consider whether SSE is enough.”
Use Cases in LLM Streaming Responses
Also, in production operations, you need to properly handle exception responses for SSE endpoints. For aligning @RestControllerAdvice handling to the ProblemDetail format, refer to Implementing Spring Boot’s GlobalExceptionHandler for Production Use. To avoid dropping long-lived connections during deployment, combining it with the shutdown configuration explained in How to Achieve Graceful Shutdown and Zero-Downtime Deployment in Spring Boot is also effective.
If you want to trigger SSE delivery from internal events, combining it with How to Loosely Couple Modules Using Spring Boot’s ApplicationEvent lets you cleanly separate business logic from the streaming layer.
A recent typical example is relaying LLM streaming responses. The pattern is to receive tokens streamed from an OpenAI-compatible API via WebClient and pass them straight through to the frontend via 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 convention 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 it easier to picture.
Summary
SSE is an option that lets you implement “just stream information from server to client” use cases with far less effort than WebSocket.
- Return
SseEmitterin Spring MVC, orFlux<ServerSentEvent>in WebFlux - Reconnection works through the combination of
EventSourceandLast-Event-ID - Operational preparation like heartbeats, Nginx buffering, and gzip is key to success
- When you need bidirectional communication, just switch to WebSocket
Try it on small use cases first, like progress notifications or streaming responses. You should feel the reassurance of handling it as an extension of HTTP.
Frequently Asked Questions (FAQ)
How should I configure the SseEmitter timeout?
Either pass a value in milliseconds via the constructor argument, or set the application-wide default with spring.mvc.async.request-timeout. For use cases that maintain long-lived connections, always specify a value shorter than the proxy’s proxy_read_timeout, so the server side disconnects first—this makes the behavior easier to predict.
How do I implement resumption with Last-Event-ID?
When the client reconnects after a disconnect, the browser automatically sends the id of the last received event in the Last-Event-ID header. On the server side, receive it with @RequestHeader("Last-Event-ID", required = false) and write logic to deliver only events newer than that ID. It’s commonly combined with persisted delivery history or queues.
How do I send a JWT Authorization header with EventSource?
By the EventSource spec, custom headers like Authorization can’t be sent. You need to either move to cookie-based session authentication, write your own SSE parser with fetch + ReadableStream, or replace it with an implementation like event-source-polyfill.
Does SSE work with HTTP/2 or HTTP/3?
Yes. In fact, HTTP/2 takes advantage of stream multiplexing per connection, which relaxes the “browser same-origin connection limit (6)” problem you hit on HTTP/1.1. If you’re going to have many long-lived connections, assuming HTTP/2 or later makes things more stable.
Should I choose SSE or WebSocket?
If there’s no active sending from client to server, first consider whether SSE is enough. WebSocket is appropriate when bidirectional communication is a premise, such as for chat or collaborative editing. For details, see Implementing Real-time Communication with WebSocket in Spring Boot.