DEV Community

Ravindra Khyalia
Ravindra Khyalia

Posted on

SSE vs WebSocket in Practice — Two Real Endpoints, One Spring Boot Server

You've probably read the theory: SSE is unidirectional, WebSocket is bidirectional, use SSE when the server pushes data and WebSocket when the client talks back. But what does that actually look like in code?

I built a small demo server (livetimeserver) that exposes both — an SSE endpoint and a WebSocket endpoint — side by side, both streaming the current time. A Next.js client (livetimeclient) connects to either one. This post walks through what's interesting about each.

Repos


The quick comparison

SSE WebSocket
Direction Server → client only Full duplex
Protocol Plain HTTP Upgraded TCP (ws://)
Browser reconnect Built-in, automatic Manual
Overhead Low (HTTP headers once) Very low (framed)
Good for Feeds, live dashboards, logs Chat, collaborative editing, games

SSE endpoint — /sse/v1/time

@GetMapping("/time")
public Flux<ServerSentEvent<String>> serverTime(
        @RequestParam(defaultValue = "0") long delayMs,
        @RequestParam(defaultValue = "0") long dropAfter) {

    Flux<Long> ticks = Flux.interval(Duration.ofSeconds(1));

    if (dropAfter > 0) {
        ticks = ticks.take(dropAfter);   // stream N events then close
    }

    Flux<ServerSentEvent<String>> events = ticks
            .map(seq -> ServerSentEvent.<String>builder()
                    .id(String.valueOf(seq))
                    .event("server_time")
                    .data(LocalDateTime.now().toString())
                    .build());

    if (delayMs > 0) {
        events = events.delayElements(Duration.ofMillis(delayMs));
    }

    return events;
}
Enter fullscreen mode Exit fullscreen mode

A few things worth noting:

Flux.interval + ServerSentEvent builder — Spring WebFlux turns a Flux return value into a streaming HTTP response automatically. No servlet threads are held; everything is non-blocking.

delayMs and dropAfter are intentionally exposed as query params. They let you simulate slow networks (delayMs=2000) and see how the browser handles a stream that ends cleanly (dropAfter=5). This makes the endpoint useful as a learning tool, not just a toy.

The browser reconnects for free. Close the server, restart it — the EventSource in the browser will keep retrying with exponential backoff. The SSE spec mandates this. You don't write a single line of reconnect logic on the client.

Client side (Next.js)

const url = `http://localhost:8080/sse/v1/time?delayMs=${delayMs}&dropAfter=${dropAfter}`;
const es = new EventSource(url);

es.addEventListener("server_time", (e) => {
  setEvents((prev) => [...prev, e.data]);
});
Enter fullscreen mode Exit fullscreen mode

That's the entire subscription. EventSource is a standard browser API — no library needed.


WebSocket endpoint — /ws/v1/time

The WebSocket side is where bidirectional communication earns its keep. The client can send {"intervalMs": 500} at any time and the server immediately switches to ticking at that rate.

@Override
public Mono<Void> handle(WebSocketSession session) {

    Sinks.Many<Long> intervalSink = Sinks.many().replay().latest();
    intervalSink.tryEmitNext(1000L); // default 1s

    // Inbound: parse interval commands from the client
    Mono<Void> inbound = session.receive()
            .flatMap(msg -> {
                try {
                    IntervalCommand cmd = objectMapper.readValue(
                            msg.getPayloadAsText(), IntervalCommand.class);
                    long ms = Math.max(100, Math.min(cmd.intervalMs(), 10_000));
                    intervalSink.tryEmitNext(ms);
                } catch (Exception ignored) {}
                return Mono.empty();
            })
            .then();

    // Outbound: switchMap cancels the old interval and starts a new one
    Flux<String> timeFlux = intervalSink.asFlux()
            .switchMap(ms -> Flux.interval(Duration.ofMillis(ms))
                    .map(_ -> LocalDateTime.now().toString()));

    Mono<Void> outbound = session.send(timeFlux.map(session::textMessage));

    return Mono.when(inbound, outbound);
}
Enter fullscreen mode Exit fullscreen mode

The key is switchMap. Every time a new intervalMs arrives in the sink, switchMap unsubscribes from the previous Flux.interval and immediately subscribes to a new one at the requested rate. The old ticks just stop. No timers to cancel, no state machine — the reactive operator does all of it.

Mono.when(inbound, outbound) keeps both pipelines alive concurrently. If you used Mono.zip you'd never complete (Mono<Void> emits no items); Mono.when completes as soon as either side terminates, which is exactly what you want when either party disconnects.

Why not use SSE here? Because the rate change comes from the client. With SSE you'd need a second HTTP request to change a server-side parameter. WebSocket lets the client and server share a single persistent channel for both directions.


Side-by-side: what the demo teaches you

Scenario SSE WebSocket
Open and just watch EventSource + addEventListener new WebSocket() + onmessage
Stop after N events Pass dropAfter=5 as a query param Server closes after N sends
Slow down events Pass delayMs=2000 as a query param Send {"intervalMs": 2000} live
Speed up events Reconnect with smaller delayMs Send {"intervalMs": 100} live
Connection drops Browser auto-reconnects Must handle in onclose

The SSE parameters have to be baked into the URL at connection time — that's the nature of a one-way channel. The WebSocket client can negotiate on the fly.


Running it yourself

# Server
git clone https://github.com/ravin-singh/livetimeserver
cd livetimeserver
./mvnw spring-boot:run
# Starts on :8080

# Client
git clone https://github.com/ravin-singh/livetimeclient
cd livetimeclient
npm install && npm run dev
# Starts on :3000
Enter fullscreen mode Exit fullscreen mode

Open http://localhost:3000, set delayMs and dropAfter, hit Connect, watch the timestamps roll in.


When to pick which

Choose SSE when:

  • The server pushes data and the client only reads (stock tickers, live logs, notification feeds)
  • You want automatic reconnection without client-side code
  • You're behind an HTTP/2 proxy (SSE multiplexes cleanly; WS needs tunnel support)

Choose WebSocket when:

  • The client sends data back (rate control, commands, chat messages)
  • You need very low latency in both directions
  • You're building something collaborative or game-like

Most real apps need both. A trading dashboard might use SSE for the price feed and WebSocket for order entry. Understanding the tradeoffs at the implementation level — not just the theory — is what this demo is designed to give you.


Stack: Spring Boot 4.0.4, Spring WebFlux, Java 25, Project Reactor, Next.js 15

Top comments (0)