skip to content

When and why would you return Flux<ServerSentEvent<T>> instead of a plain Flux<T> for an SSE endpoint?

level: middleimportance: should knowfreq 55%

answer

  1. ServerSentEvent.builder(): id/event/retry/comment/data
  2. plain Flux<T> = data field only
  3. id -> Last-Event-ID header on reconnect -> resume
  4. event name -> addEventListener
  5. comment line = heartbeat/keep-alive

basics

~20 s

Use Flux<ServerSentEvent<T>> when you need to set SSE metadata: the event name, an id (so clients can resume after reconnecting), the retry/reconnect delay, or comment lines. A plain Flux<T> only gives you the data field.

solid answer

~40 s

`ServerSentEvent<T>` is Spring's typed wrapper for a full SSE frame, built via `ServerSentEvent.builder()`. A plain `Flux<T>` only lets you populate the `data:` field, so every event is anonymous with no id. Return `Flux<ServerSentEvent<T>>` when you need: `event(name)` to let clients register named listeners (`addEventListener("price", ...)`); `id(value)` which the browser echoes as the `Last-Event-ID` header on reconnect so you can resume from where it left off; `retry(Duration)` to tell the client how long to wait before reconnecting; and `comment(text)` for heartbeat/keep-alive lines (`: ping`) that keep proxies from closing an idle connection. It's the difference between a bare data stream and a resumable, multi-channel event feed.

code

java · 20 lines
java
@GetMapping(path = "/events", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<ServerSentEvent<Event>> events(
        @RequestHeader(name = "Last-Event-ID", required = false) String lastId) {

    long from = (lastId == null) ? 0 : Long.parseLong(lastId) + 1;

    Flux<ServerSentEvent<Event>> data = service.eventsFrom(from)
        .map(e -> ServerSentEvent.<Event>builder()
            .id(String.valueOf(e.seq()))   // echoed as Last-Event-ID on reconnect
            .event("domain-event")          // client: addEventListener("domain-event", ...)
            .retry(Duration.ofSeconds(5))
            .data(e)
            .build());

    // Heartbeat: comment-only frames keep proxies from closing an idle stream
    Flux<ServerSentEvent<Event>> heartbeat = Flux.interval(Duration.ofSeconds(15))
        .map(t -> ServerSentEvent.<Event>builder().comment("ping").build());

    return Flux.merge(data, heartbeat);
}

go deeper

for a junior

Know that ServerSentEvent lets you set more than just data.

for a middle

Enumerate the builder fields and map each to its wire line and purpose.

for a senior

Design the Last-Event-ID resumption flow and heartbeat merging.

for a principal

Weigh SSE resumption/at-least-once semantics against WebSocket/RSocket or a message broker for guaranteed delivery.

**`ServerSentEvent<T>`** (in `org.springframework.http.codec`) is Spring's representation of one complete SSE frame. You build it with the fluent builder: ```java ServerSentEvent.<PriceDto>builder() .id(String.valueOf(seq)) .event("price-update") .retry(Duration.ofSeconds(5)) .data(dto) .comment("tick") .build(); ``` Each field maps to a line in the wire format: - **`data:`** — the payload. If `T` is a POJO it is JSON-serialized; if it's a `String` it's sent verbatim. This is the ONLY field a plain `Flux<T>` can populate. - **`event:`** — a **named event type**. Browser clients subscribe with `source.addEventListener("price-update", handler)`; unnamed events go to `source.onmessage`. Lets one connection multiplex several logical channels. - **`id:`** — the event id. The browser's `EventSource` remembers the last id and, on automatic reconnect, sends it back in the **`Last-Event-ID`** HTTP header. Your controller can read that header and **resume the stream** from the next item — the foundation of at-least-once SSE delivery. - **`retry:`** — an integer of milliseconds telling the client how long to wait before reconnecting after a drop. With `ServerSentEvent` you pass a `Duration`. - **`comment:`** — a line beginning with `:`. Clients ignore its content, but sending one periodically acts as a **heartbeat/keep-alive** so idle-connection timeouts (proxies, load balancers) don't kill the stream. **When plain `Flux<T>` is enough.** If you only need to push data and don't care about event names, ids, or reconnection semantics, `Flux<T>` with `produces=text/event-stream` is simpler — Spring wraps each item in a bare `data:` frame automatically. **Resumption pattern:** ```java @GetMapping(path="/events", produces=MediaType.TEXT_EVENT_STREAM_VALUE) public Flux<ServerSentEvent<Event>> events( @RequestHeader(name="Last-Event-ID", required=false) String lastId) { long from = lastId == null ? 0 : Long.parseLong(lastId) + 1; return service.eventsFrom(from) .map(e -> ServerSentEvent.<Event>builder() .id(String.valueOf(e.seq())) .event("domain-event") .data(e) .build()); } ``` **Gotchas.** - The `id` mechanism only helps if the SERVER honors `Last-Event-ID`; the browser sends it automatically, but resumption logic is your responsibility. - Heartbeat comments must be merged into the same Flux (e.g., via `Flux.merge` with a `Flux.interval` that emits comment-only events) — otherwise a slow producer looks dead to intermediaries. - `event`/`id`/`retry` are silently dropped if you serve the endpoint as `application/x-ndjson` — those metadata fields exist only in the SSE format.

  • How does a client resume an SSE stream after a dropped connection?
    The browser's EventSource stores the last received event id and, on automatic reconnect, sends it in the Last-Event-ID request header. The server reads that header and replays events from the next id onward. Resumption logic is the server's responsibility.
  • Why send periodic comment-only events?
    They act as heartbeats. Idle TCP connections can be closed by load balancers or proxies after a timeout; a periodic `: ping` comment keeps traffic flowing so the connection stays alive, and clients ignore comment content.

saying these in an interview costs you the question

  • Thinking you must use ServerSentEvent to stream at all (plain Flux works)
  • Believing the id field auto-resumes without server-side handling
  • Assuming event/id/retry survive when served as NDJSON
  • Not knowing Last-Event-ID is the reconnect header

context