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 programmaticEndpointAPI,Sessionfor sending, encoders/decoders for message conversion. - Spring WebSocket (servlet stack) offers raw
WebSocketHandlers, plus an optional STOMP sub-protocol layer:@MessageMappingcontrollers, topic subscriptions, a simple in-memory broker — with SockJS fallback for legacy browsers (largely historical now). - Spring WebFlux handles WebSockets reactively: a
WebSocketHandlercomposingFlux<WebSocketMessage>in and out — natural backpressure, no STOMP annotations. - Quarkus WebSockets Next is the modern extension (replacing the legacy Jakarta
WebSocket one): declarative
@WebSocketendpoints, per-connection state, Mutiny types, built-in broadcast. - Micronaut WebSocket:
@ServerWebSocket/@ClientWebSocketwith@OnOpen/@OnMessagemethods and aWebSocketBroadcasterfor 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/SseEventSinkinjected into a resource method,text/event-streammedia type. - Spring MVC:
SseEmitterreturned from a controller; WebFlux: returnFlux<ServerSentEvent<T>>— the cleanest expression of the model. - Quarkus: return Mutiny
Multi<T>with@Produces(MediaType.SERVER_SENT_EVENTS)(@RestStreamElementTypefor 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
- Backend protocols in Java — the map — parent overview of the protocol landscape.
- WebSockets and bidirectional protocols — the protocol-level treatment this note maps onto Java stacks.
- Messaging in Java — Kafka, JMS & AMQP — the brokers that back multi-instance fan-out.
- GraphQL on the JVM — subscriptions ride these transports.