WebSockets & STOMP
WebSockets give you a full-duplex, long-lived connection between browser and server — perfect for chat, live dashboards, notifications, and collaborative editing. Plain HTTP is request/response; WebSockets let the server push to the client at any time. Spring Boot supports both raw WebSocket handlers and the higher-level STOMP sub-protocol, which adds messaging semantics like destinations, subscriptions, and a broker.
Setup — spring-boot-starter-websocket
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
This pulls in Spring’s WebSocket and messaging support on top of spring-boot-starter-web.
Two levels of API
| Approach | What you write | When to use |
|---|---|---|
Raw WebSocketHandler | Handle each text/binary frame yourself | Simple echo, custom binary protocols |
| STOMP over WebSocket | @MessageMapping controllers + a broker | Pub/sub, multiple topics, user messaging — most apps |
STOMP is the recommended default: it gives you message routing, subscriptions, and a built-in broker, and it maps cleanly to controller methods just like REST.
Raw WebSocketHandler
For the lowest level, implement WebSocketHandler (or extend TextWebSocketHandler) and register it:
@Component
public class EchoHandler extends TextWebSocketHandler {
@Override
public void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
session.sendMessage(new TextMessage("echo: " + message.getPayload()));
}
}
@Configuration
@EnableWebSocket
public class RawWsConfig implements WebSocketConfigurer {
private final EchoHandler echoHandler;
RawWsConfig(EchoHandler echoHandler) { this.echoHandler = echoHandler; }
@Override
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
registry.addHandler(echoHandler, "/ws/echo").setAllowedOrigins("*");
}
}
This works but you manage sessions and parse payloads by hand. For anything pub/sub, use STOMP.
STOMP configuration
Enable the broker with @EnableWebSocketMessageBroker and configure endpoints and destination prefixes:
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/ws") // client connects here
.setAllowedOriginPatterns("*")
.withSockJS(); // SockJS fallback for old browsers
}
@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
registry.enableSimpleBroker("/topic", "/queue"); // in-memory broker
registry.setApplicationDestinationPrefixes("/app"); // routed to @MessageMapping
registry.setUserDestinationPrefix("/user"); // per-user messaging
}
}
What the prefixes mean:
/app— messages sent here are routed to your@MessageMappingcontroller methods./topic,/queue— handled by the simple broker; clients subscribe and the broker fans messages out./user— converts a destination into a per-session, user-specific one.
Note:
enableSimpleBrokerruns an in-memory broker — great for a single instance. For clustered deployments use a real broker (RabbitMQ/ActiveMQ) viaenableStompBrokerRelay. See RabbitMQ.
@MessageMapping controllers
STOMP controllers look like REST controllers but route by destination instead of URL:
public record ChatMessage(String from, String text) {}
@Controller
public class ChatController {
// client sends to /app/chat ; result broadcast to /topic/messages
@MessageMapping("/chat")
@SendTo("/topic/messages")
public ChatMessage send(ChatMessage incoming) {
return new ChatMessage(incoming.from(), incoming.text().trim());
}
}
@MessageMapping("/chat") matches /app/chat (the application prefix). The returned object is serialized to JSON (via Jackson) and @SendTo publishes it to /topic/messages, reaching every subscriber.
Sending to a specific user
Inject SimpMessagingTemplate to push messages programmatically, including to one user:
@Service
public class NotificationService {
private final SimpMessagingTemplate messaging;
NotificationService(SimpMessagingTemplate messaging) { this.messaging = messaging; }
public void notifyUser(String username, String text) {
// delivered to that user's subscription on /user/queue/notify
messaging.convertAndSendToUser(username, "/queue/notify", text);
}
public void broadcast(String text) {
messaging.convertAndSend("/topic/announcements", text);
}
}
convertAndSendToUser combines the user destination prefix with the session’s Principal, so each user only sees their own messages.
A JavaScript client
Browsers connect with the STOMP.js client over SockJS:
const socket = new SockJS('/ws');
const stomp = Stomp.over(socket);
stomp.connect({}, () => {
// subscribe to the broadcast topic
stomp.subscribe('/topic/messages', (frame) => {
const msg = JSON.parse(frame.body);
console.log(`${msg.from}: ${msg.text}`);
});
// send a chat message to the @MessageMapping handler
stomp.send('/app/chat', {}, JSON.stringify({ from: 'grace', text: 'hello!' }));
});
Console output (on every connected client):
grace: hello!
Handling connect / disconnect events
Listen for session lifecycle events to track presence:
@Component
public class PresenceListener {
@EventListener
public void onConnect(SessionConnectedEvent event) {
System.out.println("client connected: " + event.getMessage().getHeaders().get("simpSessionId"));
}
@EventListener
public void onDisconnect(SessionDisconnectEvent event) {
System.out.println("client gone: " + event.getSessionId());
}
}
Pitfalls
- CORS: WebSocket origins are separate from MVC CORS — set
setAllowedOriginPatterns(...)on the endpoint. See CORS. - The simple broker does not survive across multiple instances; use a broker relay for horizontal scaling.
- WebSocket sessions are stateful and consume memory — clean up on disconnect for large fan-outs.
- Authentication: secure the handshake with Spring Security and read the
PrincipalforconvertAndSendToUser.