WebSockets with STOMP
Push live updates to browsers over WebSockets using STOMP destinations, @MessageMapping handlers and SimpMessagingTemplate.
@EnableWebSocketMessageBroker + WebSocketMessageBrokerConfigurer
registry.addEndpoint("/ws"); registry.enableSimpleBroker("/topic");
@MessageMapping("/x") @SendTo("/topic/x")
messagingTemplate.convertAndSend("/topic/x", payload);HTTP makes the client ask for every update. A WebSocket keeps one connection open in both directions, so the server can push an order status change or a chat message the moment it happens. Raw WebSockets only carry frames, though; you would have to invent your own message format and routing. STOMP is a small text protocol on top that adds destinations, subscriptions and headers, and Spring maps it onto familiar annotated handlers.
Setting it up
Add spring-boot-starter-websocket, then put @EnableWebSocketMessageBroker on a configuration class that implements WebSocketMessageBrokerConfigurer. It defines three things:
- An endpoint, such as
/ws, where the browser opens the WebSocket. - An application prefix, usually
/app: messages sent to/app/...go to your@MessageMappingmethods. - A broker for
/topicand/queue: messages sent there are delivered to every subscriber.
A browser client, here with the @stomp/stompjs library, connects and subscribes:
const client = new Client({ brokerURL: 'wss://api.example.com/ws' });
client.onConnect = () => {
client.subscribe('/topic/orders/42', frame => render(JSON.parse(frame.body)));
};
client.activate();Handlers and server push
A @MessageMapping method in a @Controller receives a payload converted from JSON, and its return value goes to the destination in @SendTo. Most API updates, though, start somewhere else: a REST call or a Kafka listener changes an order. Inject SimpMessagingTemplate and call convertAndSend("/topic/orders/42", update) from any bean. convertAndSendToUser targets one user's sessions through /user/queue/....
Security
The WebSocket handshake is an HTTP request, so your SecurityFilterChain protects /ws and the authenticated user becomes the Principal of every STOMP message. Browsers cannot set an Authorization header on a WebSocket handshake, which matters for JWT-based APIs: either use a cookie, or authenticate the STOMP CONNECT frame with a channel interceptor. Restrict allowed origins; a wildcard lets any site open a socket with your users' cookies.
An authenticated handshake does not decide who may subscribe to what. Any signed-in user can send SUBSCRIBE /topic/orders/42 and receive another customer's updates. Send private data to user destinations, or add spring-security-messaging with @EnableWebSocketSecurity and an AuthorizationManager<Message<?>> bean that checks SUBSCRIBE and SEND destinations.
Scaling out
The simple broker lives in memory in one instance. With two instances behind a load balancer, a message published on instance A never reaches a browser connected to instance B. For more than one instance, relay to a real STOMP broker such as RabbitMQ with its STOMP plugin. RabbitMQ turns the part after /topic/ into an AMQP routing key, where the separator is a dot, so use destinations such as /topic/orders.42 rather than /topic/orders/42. Call registry.setPathMatcher(new AntPathMatcher(".")) so @MessageMapping("rooms.{roomId}.messages") patterns match dot-separated destinations too. Each open socket also holds memory and a connection through your proxy, so check idle timeouts on the load balancer.
Example
import java.security.Principal;
import java.time.Instant;
import org.springframework.context.annotation.Configuration;
import org.springframework.messaging.handler.annotation.DestinationVariable;
import org.springframework.messaging.handler.annotation.MessageMapping;
import org.springframework.messaging.handler.annotation.SendTo;
import org.springframework.messaging.simp.config.MessageBrokerRegistry;
import org.springframework.stereotype.Controller;
import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker;
import org.springframework.web.socket.config.annotation.StompEndpointRegistry;
import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer;
record IncomingMessage(String text) {
}
record ChatMessage(String roomId, String author, String text, Instant sentAt) {
}
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/ws").setAllowedOriginPatterns("https://*.example.com");
}
public void configureMessageBroker(MessageBrokerRegistry registry) {
registry.setApplicationDestinationPrefixes("/app");
registry.enableSimpleBroker("/topic", "/queue");
}
}
class ChatController {
("/rooms/{roomId}/messages")
("/topic/rooms/{roomId}")
ChatMessage send( String roomId, IncomingMessage message, Principal user) {
String author = user != null ? user.getName() : "anonymous";
return new ChatMessage(roomId, author, message.text().strip(), Instant.now());
}
}
When to use it
- Show a live order tracker that moves from paid to packed to shipped without the page polling the API.
- Push a notification to one signed-in user on all their open tabs when a report they requested is ready.
- Broadcast new bids to everyone watching an auction across three load-balanced application instances.
More examples
Pushing order updates from a service
Call this from a REST controller, a scheduled job or a Kafka listener: anything that changes an order. The user destination is resolved per session, so a customer subscribed to <code>/user/queue/notifications</code> receives only their own notifications, on every tab and device they have open.
import java.time.Instant;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.stereotype.Service;
record OrderStatusChanged(long orderId, String status, Instant at) {
}
record Notification(String message) {
}
@Service
public class OrderStatusNotifier {
private final SimpMessagingTemplate messaging;
OrderStatusNotifier(SimpMessagingTemplate messaging) {
this.messaging = messaging;
}
public void statusChanged(long orderId, String customerUsername, String status) {
messaging.convertAndSend("/topic/orders/" + orderId,
new OrderStatusChanged(orderId, status, Instant.now()));
messaging.convertAndSendToUser(customerUsername, "/queue/notifications",
new Notification("Order " + orderId + " is now " + status));
}
}
Returning errors to the sender only
Without a handler, a failing <code>@MessageMapping</code> method is only logged and the client never learns why its message vanished. <code>@SendToUser</code> with <code>broadcast = false</code> replies to the one session that sent the bad bid, not to the user's other tabs.
import org.springframework.messaging.handler.annotation.MessageExceptionHandler;
import org.springframework.messaging.handler.annotation.MessageMapping;
import org.springframework.messaging.handler.annotation.SendTo;
import org.springframework.messaging.simp.annotation.SendToUser;
import org.springframework.stereotype.Controller;
record Bid(long auctionId, long amountPence) {
}
record ErrorMessage(String error) {
}
@Controller
public class AuctionController {
@MessageMapping("/bids")
@SendTo("/topic/bids")
Bid placeBid(Bid bid) {
if (bid.amountPence() <= 0) {
throw new IllegalArgumentException("Bid must be positive");
}
return bid;
}
@MessageExceptionHandler
@SendToUser(destinations = "/queue/errors", broadcast = false)
ErrorMessage handle(IllegalArgumentException ex) {
return new ErrorMessage(ex.getMessage());
}
}
Relaying to RabbitMQ for several instances
Every instance forwards subscriptions and messages to RabbitMQ's STOMP plugin on port 61613, so a message sent on one instance reaches subscribers on all of them. RabbitMQ uses the text after <code>/topic/</code> as a routing key, which is dot-separated, so destinations here use dots: a client sends to <code>/app/rooms.7.messages</code> and subscribes to <code>/topic/rooms.7</code>. <code>AntPathMatcher(".")</code> makes <code>@MessageMapping</code> patterns and <code>{roomId}</code> variables work with that separator. The two broadcast destinations share each instance's connected users with the others, so <code>convertAndSendToUser</code> reaches a user whose socket is open on a different instance. The relay's TCP client needs Reactor Netty on the classpath. Credentials come from <code>app.stomp.*</code> properties set through environment variables.
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Configuration;
import org.springframework.messaging.handler.annotation.DestinationVariable;
import org.springframework.messaging.handler.annotation.MessageMapping;
import org.springframework.messaging.handler.annotation.SendTo;
import org.springframework.messaging.simp.config.MessageBrokerRegistry;
import org.springframework.stereotype.Controller;
import org.springframework.util.AntPathMatcher;
import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker;
import org.springframework.web.socket.config.annotation.StompEndpointRegistry;
import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer;
record RoomMessage(String roomId, String text) {
}
@Configuration
@EnableWebSocketMessageBroker
public class RelayWebSocketConfig implements WebSocketMessageBrokerConfigurer {
private final String relayHost;
private final String relayUser;
private final String relayPassword;
RelayWebSocketConfig(@Value("${app.stomp.host}") String relayHost,
@Value("${app.stomp.user}") String relayUser,
@Value("${app.stomp.password}") String relayPassword) {
this.relayHost = relayHost;
this.relayUser = relayUser;
this.relayPassword = relayPassword;
}
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/ws").setAllowedOriginPatterns("https://app.example.com");
}
@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
registry.setPathMatcher(new AntPathMatcher("."));
registry.setApplicationDestinationPrefixes("/app");
registry.enableStompBrokerRelay("/topic", "/queue")
.setRelayHost(relayHost)
.setRelayPort(61613)
.setClientLogin(relayUser)
.setClientPasscode(relayPassword)
.setSystemLogin(relayUser)
.setSystemPasscode(relayPassword)
.setUserDestinationBroadcast("/topic/unresolved.user")
.setUserRegistryBroadcast("/topic/user.registry");
}
}
@Controller
class RoomController {
@MessageMapping("rooms.{roomId}.messages")
@SendTo("/topic/rooms.{roomId}")
RoomMessage send(@DestinationVariable String roomId, RoomMessage message) {
return new RoomMessage(roomId, message.text().strip());
}
}
Discussion