rgoussu@goussu: ~/library/java/protocols
~/library/java/protocols cat websockets.md

WebSockets & SSE in Java

# Jakarta WebSocket, Spring's STOMP layer, Quarkus WebSockets Next and Micronaut WebSocket, with SSE as the cheaper one-way option and fan-out scaling.

Conceptsaved 2026-08-09 #java#protocols#websockets#sse#frameworks

Overview

When the server must push — live dashboards, chat, collaborative editing, notifications — request/response stops fitting and you reach for a held-open connection. WebSockets give a full-duplex message channel upgraded from HTTP; Server-Sent Events (SSE) give a one-way server-to-client stream over plain HTTP that is dramatically cheaper to operate. Java has a standard (Jakarta WebSocket), a Spring layer that adds messaging semantics on top (STOMP), and modern Quarkus/Micronaut APIs — and in all of them the hard part is not the endpoint but scaling stateful connections across instances.

Key points

  • Jakarta WebSocket (originally JSR 356) is the spec: annotated endpoints (@ServerEndpoint, @OnOpen, @OnMessage, @OnClose), a programmatic Endpoint API, Session for sending, encoders/decoders for message conversion.
  • Spring WebSocket (servlet stack) offers raw WebSocketHandlers, plus an optional STOMP sub-protocol layer: @MessageMapping controllers, topic subscriptions, a simple in-memory broker — with SockJS fallback for legacy browsers (largely historical now).
  • Spring WebFlux handles WebSockets reactively: a WebSocketHandler composing Flux<WebSocketMessage> in and out — natural backpressure, no STOMP annotations.
  • Quarkus WebSockets Next is the modern extension (replacing the legacy Jakarta WebSocket one): declarative @WebSocket endpoints, per-connection state, Mutiny types, built-in broadcast.
  • Micronaut WebSocket: @ServerWebSocket/@ClientWebSocket with @OnOpen/ @OnMessage methods and a WebSocketBroadcaster for fan-out.
  • SSE is the underrated default for one-way push: plain HTTP, auto-reconnect with Last-Event-ID, proxy-friendly; only step up to WebSockets when the client must send.
  • Scaling is the real problem: connections are stateful, so horizontal scale needs sticky sessions or (better) an external broker/pub-sub relaying events to whichever instance holds the connection.

Details

WebSocket APIs per stack

Concern Jakarta WebSocket Spring (servlet) Spring WebFlux Quarkus WebSockets Next Micronaut
Endpoint @ServerEndpoint("/ws") WebSocketHandler + registry reactive WebSocketHandler @WebSocket(path = "/ws") @ServerWebSocket("/ws")
Receive @OnMessage handleMessage session.receive(): Flux @OnTextMessage @OnMessage
Send session.getBasicRemote() session.sendMessage session.send(Flux) return value / WebSocketConnection WebSocketBroadcaster
Higher-level messaging STOMP + @MessageMapping broadcast built-in broadcaster
Client ContainerProvider WebSocketClient / STOMP client ReactorNettyWebSocketClient @WebSocketClient @ClientWebSocket

STOMP: messaging semantics over the socket

Raw WebSocket is just framed bytes — no topics, no acks, no routing. Spring's STOMP layer adds destination-based messaging: clients SUBSCRIBE to /topic/..., controllers handle @MessageMapping("/app/..."), and a broker dispatches. The default simple broker is in-memory and single-instance; production fan-out swaps it for a broker relay to a real STOMP broker (RabbitMQ, ActiveMQ), which also solves multi-instance delivery. The other stacks leave this layer to you — or to a protocol like GraphQL subscriptions riding on the socket.

SSE per stack

  • Jakarta REST: Sse/SseEventSink injected into a resource method, text/event-stream media type.
  • Spring MVC: SseEmitter returned from a controller; WebFlux: return Flux<ServerSentEvent<T>> — the cleanest expression of the model.
  • Quarkus: return Mutiny Multi<T> with @Produces(MediaType.SERVER_SENT_EVENTS) (@RestStreamElementType for the element type).
  • Micronaut: return a Publisher<Event<T>> from a controller method.

SSE's operational virtues: it is plain HTTP (works through proxies, CDNs and HTTP/2 multiplexing), the browser EventSource reconnects automatically, and Last-Event-ID gives resumability for free. Its limits: one-way, text-only framing.

Scaling fan-out

  • Sticky sessions keep a client pinned to the instance holding its socket — necessary but not sufficient: an event produced on instance A must still reach a subscriber connected to instance B.
  • Broker-backed fan-out is the standard answer: publish events to Redis pub/sub, Kafka or a STOMP/AMQP broker; every instance subscribes and forwards to its local connections. Spring's STOMP broker relay is this pattern productised.
  • Budget connections: each socket holds memory and a slot; virtual threads ease the thread cost on the JVM but not the fan-out problem. Idle timeouts, heartbeats (ping/pong), and reconnect-with-backoff on the client are table stakes.

Examples

// Quarkus WebSockets Next
@WebSocket(path = "/chat/{room}")
public class ChatSocket {
    @OnTextMessage(broadcast = true)      // echo to every connection on this path
    public String onMessage(String message) { return message; }
}
// Spring WebFlux SSE — one-way push, no socket needed
@GetMapping(path = "/ticks", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
Flux<ServerSentEvent<String>> ticks() {
    return Flux.interval(Duration.ofSeconds(1))
               .map(i -> ServerSentEvent.builder("tick " + i).id(String.valueOf(i)).build());
}

Related