Here is the English translation of the article body.
There are plenty of situations where you want to push data one-way from the server to the client, such as job progress notifications or streaming responses from an LLM. When you find yourself thinking “this doesn’t quite justify pulling in WebSocket…”, Server-Sent Events (SSE) are exactly the right fit.
This article covers both the Spring MVC SseEmitter implementation and the WebFlux Flux<ServerSentEvent> implementation, how to consume the stream on the client side, and the pitfalls you are likely to hit in production.
What Are Server-Sent Events (SSE)?
SSE is a mechanism for pushing data one-way from the server to the client while keeping a single HTTP/1.1 connection open. The Content-Type is text/event-stream, and browsers support it natively through the EventSource API.
Compared to WebSocket, the main benefits are:
- It is plain HTTP, so your existing infrastructure for authentication, proxies, and so on works as-is
- The browser reconnects automatically
- The
Last-Event-IDheader lets the client tell the server where to resume from
It also works fine over HTTP/2 and HTTP/3. In fact, HTTP/2 multiplexes streams within a single connection, which relaxes the browser’s per-origin connection limit (six connections) that becomes a problem with HTTP/1.1. If you plan to hold many long-lived connections, assuming HTTP/2 or later will give you more stable behavior.
On the other hand, if the client needs to send something to the server, it has to call a regular REST API. If you need fully bidirectional communication, take a look at Implementing Real-Time Communication with WebSocket in Spring Boot instead.
Using SseEmitter in Spring MVC
Let’s start with the minimal Spring MVC implementation. All you need to do is set produces to text/event-stream and return an 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 to offload sending to a separate thread. The basic pattern is to return from the controller thread immediately, hold on to a reference to the SseEmitter, and call send() on it in the background.
The SseEmitter.event() builder lets you specify id, name, reconnectTime, and data. The id is critical for the Last-Event-ID resumption described later, so always set it if your stream is meant to be resumable.
The timeout can be set via the constructor argument, or globally with spring.mvc.async.request-timeout in application.properties. Leaving the default in place tends to cause unexpected disconnects, so it is best to set it explicitly for your use case.
Using Flux<ServerSentEvent<T>> in Spring WebFlux
On the reactive stack, the code is even more straightforward. Just return a 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, its big advantage is that it can handle thousands to tens of thousands of concurrent connections with a small number of threads. That makes it a particularly good match for use cases like SSE, where connections stay open for a long time. For the basics of reactive programming, see An Introduction to Reactive Programming with Spring WebFlux.
Incidentally, if you pass a non-string object to data, it will be automatically serialized to JSON for you.
Receiving Events on the Client (EventSource)
On the browser side, EventSource gets the job done in 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. On reconnect, 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 events the client missed.
@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);
// startFrom 以降のイベントだけ送る
...
}
For full-fledged resumable delivery, the common approach is to combine this with a persisted delivery history or a queue, and pull out only the events newer than Last-Event-ID to send.
Note that EventSource has one limitation: it cannot send custom headers (such as Authorization). If cookie-based authentication is enough, that is the easiest path. If you need to use JWT, consider parsing the stream yourself with fetch + ReadableStream, or switching to an implementation such as event-source-polyfill.
Timeouts and Heartbeats
With long-lived connections, the biggest enemy is a stretch of time where nothing flows. Proxies and load balancers may decide the connection is “too idle” and cut it off.
The fix is simple: send a comment line periodically. A comment is a line starting with :. The client ignores it, but it keeps the TCP connection alive.
@Scheduled(fixedRate = 15000)
public void heartbeat() {
emitters.forEach(emitter -> {
try {
emitter.send(SseEmitter.event().comment("ping"));
} catch (IOException e) {
emitter.complete();
}
});
}
Keep in mind that the MVC SseEmitter occupies one asynchronous servlet thread per connection, so you need to pay attention to server.tomcat.threads.max and the connection limits. If you expect several thousand or more concurrent connections, it is safer to choose WebFlux from the start.
Broadcasting to Multiple Clients and Lifecycle Management
In real-world operation, rather than one stream per request, you often run into the scenario of “deliver the same event to multiple clients subscribed to the same topic.” The standard approach is to manage SseEmitter instances in a thread-safe collection and reliably clean them up in 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 do not remove emitters 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 are putting the MVC SseEmitter into production, make sure you implement this. With WebFlux, the natural way to broadcast is with Sinks.Many.
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();
}
Integrating with Spring Security and CORS
An SSE endpoint is just an HTTP GET, so you can write authorization rules for it in Spring Security’s SecurityFilterChain as usual. There are two things to watch out for.
- Because
EventSourcecannot send custom headers, JWT authentication that relies on the Authorization header effectively has to fall back to cookie-based sessions. - When connecting from a different origin, you need CORS configuration that allows
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, new EventSource(url, { withCredentials: true }) sends cookies along with the request. For more on authentication, see also How to Implement Google Login (OAuth2) in Spring Boot.
Testing SSE Endpoints
The Spring MVC SseEmitter can be verified through MockMvc using asyncDispatch. For WebFlux, the standard approach is to consume 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 then disconnect” using take(n) and thenCancel() is practical. On the MVC side, if the stream sends events through to completion, a convenient alternative is to split mvcResult.getResponse().getContentAsString() on \n\n with MockMvc and verify the pieces.
Reverse Proxy Settings That Commonly Cause Trouble
When you put Nginx in front of your app in production, a common accident is that proxy_buffering is on by default, 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 the server also returns X-Accel-Buffering: no, Nginx will turn off buffering for that particular response. It is also a good idea to set the SseEmitter timeout to a value shorter than proxy_read_timeout, so that the server side disconnects first. This makes the behavior more predictable.
Another surprising pitfall is gzip compression. Events get delayed while they sit in the compression buffer, so it is safest to exclude SSE endpoints from gzip.
Choosing Between SSE and WebSocket
Here is a table of criteria to help you decide when you are unsure.
| Aspect | SSE | WebSocket |
|---|---|---|
| Direction | One-way, server to client | Bidirectional |
| Protocol | HTTP/1.1 (text/event-stream) | Dedicated protocol (Upgrade) |
| Auto-reconnect | Built into browsers | Must be implemented yourself |
| Proxies and auth | Easy, since it is plain HTTP | Often requires dedicated configuration |
| Implementation cost | Low | Somewhat higher |
| Suitable use cases | Progress notifications, LLM streaming, dashboards | Chat, collaborative editing, games |
Roughly speaking, a good order of evaluation is: “if the client doesn’t need to actively send anything, first ask whether SSE is enough.”
Where SSE Fits: LLM Streaming Responses
A typical recent example is relaying streaming responses from an LLM. The pattern is to receive the tokens flowing from an OpenAI-compatible API with WebClient and pass them straight through to the frontend over 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 conventional practice is to signal completion with a custom event such as event: done and have the client call es.close(). Reading Choosing Between RestTemplate and WebClient in Spring Boot alongside this should help you picture how to use WebClient.
In production, you also need to shape the error responses of your SSE endpoints. For how to standardize @RestControllerAdvice handling on the ProblemDetail format, see Implementing a Production-Ready GlobalExceptionHandler in Spring Boot. To avoid dropping long-lived connections during deployments, combining this with the shutdown settings explained in How to Achieve Graceful Shutdown and Zero-Downtime Deployment in Spring Boot is also effective.
If you want to deliver events over SSE triggered by internal application events, combining this with How to Decouple Modules with ApplicationEvent in Spring Boot lets you cleanly separate your business logic from the streaming layer.
Summary
For use cases that only need to “steadily push information from the server to the client,” SSE is an option that takes far less effort to implement than WebSocket.
- Return
SseEmitterin Spring MVC, orFlux<ServerSentEvent>in WebFlux - Reconnection works through the combination of
EventSourceandLast-Event-ID - Operational preparation such as heartbeats, Nginx buffering, and gzip is the key to success
- Once you need bidirectional communication, simply switch to WebSocket
Start by trying it out on a small use case such as progress notifications or streaming responses. You should quickly come to appreciate the peace of mind that comes from working within the bounds of plain HTTP.