Skip to content
Spring Boot sb web 3 min read

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, register onCompletion / onTimeout callbacks to remove dead ones, and loop over them to send.

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 id back in a Last-Event-ID header, 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)) on ServerSentEvent).
@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

AspectSSEWebSockets
DirectionOne-way (server → client)Full-duplex
ProtocolPlain HTTP, text/event-streamUpgraded ws:// / wss://
Browser APIEventSource (built-in)WebSocket + STOMP libs
Auto-reconnectYes, with Last-Event-IDManual
Data formatUTF-8 text onlyText or binary
Best forNotifications, progress, feedsChat, games, collaboration
ComplexityLowHigher

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 SseEmitter holds 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.
Last updated June 13, 2026
Was this helpful?