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
- Server: https://github.com/ravin-singh/livetimeserver (Spring Boot 4 + WebFlux, Java 25)
- Client: https://github.com/ravin-singh/livetimeclient (Next.js)
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;
}
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]);
});
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);
}
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
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)