Spring Boot: WebSocket with STOMP
WebSockets provide full-duplex communication between client and server over a single persistent TCP connection. Combined with STOMP (Simple Text Oriented Messaging Protocol), Spring Boot offers a powerful publish-subscribe messaging model for real-time applications.
Contents
What Are WebSockets
HTTP follows a request-response model where the client initiates every interaction. WebSocket upgrades an HTTP connection into a persistent, full-duplex channel where both client and server can send messages independently at any time.
| Feature | HTTP | WebSocket |
|---|---|---|
| Communication | Request-response (half-duplex) | Full-duplex |
| Connection | New connection per request (HTTP/1.1 keep-alive reuses) | Single persistent connection |
| Overhead | Headers sent with every request | Minimal framing after handshake |
| Server Push | Not natively supported | Server can push at any time |
| Protocol | HTTP/HTTPS | ws:// or wss:// |
Common use cases for WebSockets include:
- Real-time chat applications
- Live notifications and alerts
- Collaborative editing (documents, whiteboards)
- Live dashboards and stock tickers
- Multiplayer gaming
- IoT device communication
Spring Boot uses STOMPCONNECTSUBSCRIBESENDMESSAGE
@MessageMapping@RequestMappingDependencies
Add the spring-boot-starter-websocketSockJS
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
<!-- Optional: for securing WebSocket endpoints -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
On the client side, include the SockJS and STOMP.js libraries. These can be loaded from a CDN or bundled with your frontend build:
<script src="https://cdn.jsdelivr.net/npm/sockjs-client@1/dist/sockjs.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/stomp.js/2.3.3/stomp.min.js"></script>
SockJSConfiguring WebSocket with STOMP
Create a configuration class that implements WebSocketMessageBrokerConfigurer@EnableWebSocketMessageBroker
import org.springframework.context.annotation.Configuration;
import org.springframework.messaging.simp.config.MessageBrokerRegistry;
import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker;
import org.springframework.web.socket.config.annotation.StompEndpointRegistry;
import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer;
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
// Clients connect to this endpoint to establish a WebSocket connection
registry.addEndpoint("/ws")
.setAllowedOrigins("http://localhost:3000")
.withSockJS(); // Enable SockJS fallback
}
@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
// Prefix for messages FROM client TO server (@MessageMapping destinations)
registry.setApplicationDestinationPrefixes("/app");
// Enable a simple in-memory broker for subscriptions
// Clients subscribe to /topic/* for broadcasts and /queue/* for private messages
registry.enableSimpleBroker("/topic", "/queue");
// Prefix for user-specific destinations (used with @SendToUser)
registry.setUserDestinationPrefix("/user");
}
}
The configuration above defines three key aspects of the messaging architecture:
: defines the HTTP URL where the WebSocket handshake occurs. TheregisterStompEndpoints() call enables fallback transports.withSockJS() : messages sent to destinations starting withsetApplicationDestinationPrefixes("/app") are routed to/app methods in your controllers.@MessageMapping : activates the built-in message broker. Clients subscribing toenableSimpleBroker("/topic", "/queue") or/topic/... receive messages relayed by the broker./queue/...
registry.enableStompBrokerRelay("/topic", "/queue")Message Controller
Use @MessageMapping@RequestMapping@SendTo
import org.springframework.messaging.handler.annotation.MessageMapping;
import org.springframework.messaging.handler.annotation.SendTo;
import org.springframework.messaging.handler.annotation.Payload;
import org.springframework.messaging.handler.annotation.Header;
import org.springframework.stereotype.Controller;
@Controller
public class ChatController {
// Client sends to /app/chat.send, response goes to /topic/public
@MessageMapping("/chat.send")
@SendTo("/topic/public")
public ChatMessage sendMessage(@Payload ChatMessage message) {
return message;
}
// Client sends to /app/chat.join, response goes to /topic/public
@MessageMapping("/chat.join")
@SendTo("/topic/public")
public ChatMessage addUser(@Payload ChatMessage message,
@Header("simpSessionId") String sessionId) {
message.setType(ChatMessage.MessageType.JOIN);
return message;
}
}
The message payload class:
public class ChatMessage {
public enum MessageType { CHAT, JOIN, LEAVE }
private MessageType type;
private String content;
private String sender;
public ChatMessage() {}
public ChatMessage(MessageType type, String content, String sender) {
this.type = type;
this.content = content;
this.sender = sender;
}
public MessageType getType() { return type; }
public void setType(MessageType type) { this.type = type; }
public String getContent() { return content; }
public void setContent(String content) { this.content = content; }
public String getSender() { return sender; }
public void setSender(String sender) { this.sender = sender; }
}
The message flow works as follows:
- Client sends a STOMP
frame toSEND ./app/chat.send - Spring strips the
prefix and routes to/app .@MessageMapping("/chat.send") - The method processes the message and returns a
.ChatMessage - The return value is sent to
(via/topic/public ), and all subscribers receive it.@SendTo
@DestinationVariable@MessageMapping("/chat.{roomId}")@DestinationVariable String roomIdSending Messages from Server
While @SendToSimpMessagingTemplate
import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
@Service
public class NotificationService {
private final SimpMessagingTemplate messagingTemplate;
public NotificationService(SimpMessagingTemplate messagingTemplate) {
this.messagingTemplate = messagingTemplate;
}
// Broadcast to all subscribers of /topic/notifications
public void sendNotification(String message) {
Notification notification = new Notification(message, LocalDateTime.now());
messagingTemplate.convertAndSend("/topic/notifications", notification);
}
// Push stock price updates every 2 seconds
@Scheduled(fixedRate = 2000)
public void sendStockPriceUpdate() {
StockPrice price = StockPrice.randomUpdate("AAPL");
messagingTemplate.convertAndSend("/topic/stocks", price);
}
// Send to a specific user (requires authentication)
public void sendToUser(String username, String message) {
messagingTemplate.convertAndSendToUser(
username, "/queue/private", new Notification(message, LocalDateTime.now())
);
}
}
You can also inject SimpMessagingTemplate
import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class AdminController {
private final SimpMessagingTemplate messagingTemplate;
public AdminController(SimpMessagingTemplate messagingTemplate) {
this.messagingTemplate = messagingTemplate;
}
@PostMapping("/api/admin/broadcast")
public String broadcastAnnouncement(@RequestBody String announcement) {
messagingTemplate.convertAndSend("/topic/announcements", announcement);
return "Broadcast sent";
}
}
SimpMessagingTemplate@EnableWebSocketMessageBrokerconvertAndSend()convertAndSendToUser()JavaScript Client
Use SockJSSTOMP.js
const socket = new SockJS('/ws');
const stompClient = Stomp.over(socket);
// Optional: disable debug logging in production
// stompClient.debug = null;
stompClient.connect({}, function(frame) {
console.log('Connected: ' + frame);
// Subscribe to the public chat topic
stompClient.subscribe('/topic/public', function(message) {
const chatMessage = JSON.parse(message.body);
displayMessage(chatMessage);
});
// Subscribe to private messages (user-specific)
stompClient.subscribe('/user/queue/private', function(message) {
const notification = JSON.parse(message.body);
showNotification(notification);
});
// Announce that this user has joined
stompClient.send('/app/chat.join', {},
JSON.stringify({ sender: username, type: 'JOIN' })
);
}, function(error) {
console.error('WebSocket connection error: ' + error);
// Implement reconnection logic
setTimeout(connectWebSocket, 5000);
});
// Send a chat message
function sendMessage(content) {
if (stompClient && stompClient.connected) {
stompClient.send('/app/chat.send', {},
JSON.stringify({ sender: username, content: content, type: 'CHAT' })
);
}
}
// Disconnect gracefully
function disconnect() {
if (stompClient !== null) {
stompClient.disconnect(function() {
console.log('Disconnected');
});
}
}
Key points about the client code:
: connects to the STOMP endpoint registered innew SockJS('/ws') .WebSocketConfig : registers a callback for messages arriving at the given destination.stompClient.subscribe() : sends a STOMP SEND frame. The second argument is headers (empty object here), and the third is the body.stompClient.send()- The
subscription resolves to a user-specific queue thanks to the/user/queue/private configured on the server.userDestinationPrefix
User-Specific Messages
To send messages to a specific authenticated user rather than broadcasting, use @SendToUserconvertAndSendToUser()SimpMessagingTemplatePrincipal
import org.springframework.messaging.handler.annotation.MessageMapping;
import org.springframework.messaging.simp.annotation.SendToUser;
import org.springframework.stereotype.Controller;
import java.security.Principal;
@Controller
public class PrivateMessageController {
// Client sends to /app/private.message
// Response is sent ONLY to the requesting user at /user/queue/reply
@MessageMapping("/private.message")
@SendToUser("/queue/reply")
public PrivateReply handlePrivateMessage(PrivateRequest request,
Principal principal) {
String username = principal.getName();
// Process the request and return a reply only to the sender
return new PrivateReply("Received your message: " + request.getContent(), username);
}
}
To send a message to a different user (not the sender), use SimpMessagingTemplate
import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.stereotype.Service;
@Service
public class DirectMessageService {
private final SimpMessagingTemplate messagingTemplate;
public DirectMessageService(SimpMessagingTemplate messagingTemplate) {
this.messagingTemplate = messagingTemplate;
}
// Send a direct message to a specific user
public void sendDirectMessage(String recipientUsername, Object payload) {
// The user subscribes to /user/queue/direct on the client side
messagingTemplate.convertAndSendToUser(
recipientUsername, "/queue/direct", payload
);
}
// Send an error notification to a specific user
public void sendError(String username, String errorMessage) {
messagingTemplate.convertAndSendToUser(
username, "/queue/errors", errorMessage
);
}
}
On the client side, the user subscribes to their personal queue:
// Subscribe to direct messages — Spring resolves /user/ to the current user
stompClient.subscribe('/user/queue/direct', function(message) {
const dm = JSON.parse(message.body);
displayDirectMessage(dm);
});
// Subscribe to error notifications
stompClient.subscribe('/user/queue/errors', function(message) {
showError(message.body);
});
/user/queue/direct/queue/direct-user{sessionId}/user/queue/directPrincipalSecuring WebSocket Endpoints
WebSocket security involves two layers: securing the initial HTTP handshake and authorizing STOMP message destinations. Spring Security integrates with WebSocket to provide both.
First, configure HTTP security to protect the WebSocket handshake endpoint:
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/ws/**").authenticated()
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.anyRequest().permitAll()
)
.formLogin(form -> form.defaultSuccessUrl("/chat.html"))
.csrf(csrf -> csrf
// SockJS uses iframes and POST for fallback transports
.ignoringRequestMatchers("/ws/**")
);
return http.build();
}
}
Next, add message-level security to control who can send or subscribe to specific destinations:
import org.springframework.context.annotation.Configuration;
import org.springframework.messaging.simp.SimpMessageType;
import org.springframework.security.config.annotation.web.messaging.MessageSecurityMetadataSourceRegistry;
import org.springframework.security.config.annotation.web.socket.AbstractSecurityWebSocketMessageBrokerConfigurer;
@Configuration
public class WebSocketSecurityConfig
extends AbstractSecurityWebSocketMessageBrokerConfigurer {
@Override
protected void configureInbound(
MessageSecurityMetadataSourceRegistry messages) {
messages
// Allow connection and disconnect without restrictions
.simpTypeMatchers(
SimpMessageType.CONNECT,
SimpMessageType.DISCONNECT,
SimpMessageType.HEARTBEAT
).permitAll()
// Only authenticated users can subscribe to topics
.simpSubscribeDestMatchers("/topic/**").authenticated()
.simpSubscribeDestMatchers("/user/**").authenticated()
// Only authenticated users can send messages
.simpDestMatchers("/app/**").authenticated()
// Admin-only destinations
.simpSubscribeDestMatchers("/topic/admin/**").hasRole("ADMIN")
.simpDestMatchers("/app/admin.**").hasRole("ADMIN")
// Deny everything else
.anyMessage().denyAll();
}
@Override
protected boolean sameOriginDisabled() {
// Disable CSRF for WebSocket — handled at HTTP level
return true;
}
}
You can also intercept and validate messages using a ChannelInterceptor
import org.springframework.context.annotation.Configuration;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.messaging.Message;
import org.springframework.messaging.MessageChannel;
import org.springframework.messaging.simp.config.ChannelRegistration;
import org.springframework.messaging.simp.stomp.StompCommand;
import org.springframework.messaging.simp.stomp.StompHeaderAccessor;
import org.springframework.messaging.support.ChannelInterceptor;
import org.springframework.messaging.support.MessageHeaderAccessor;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer;
@Configuration
@Order(Ordered.HIGHEST_PRECEDENCE + 99)
public class WebSocketAuthInterceptor implements WebSocketMessageBrokerConfigurer {
private final TokenAuthenticationService tokenService;
public WebSocketAuthInterceptor(TokenAuthenticationService tokenService) {
this.tokenService = tokenService;
}
@Override
public void configureClientInboundChannel(ChannelRegistration registration) {
registration.interceptors(new ChannelInterceptor() {
@Override
public Message<?> preSend(Message<?> message, MessageChannel channel) {
StompHeaderAccessor accessor =
MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class);
if (StompCommand.CONNECT.equals(accessor.getCommand())) {
String token = accessor.getFirstNativeHeader("Authorization");
if (token != null) {
UsernamePasswordAuthenticationToken auth =
tokenService.validateToken(token);
accessor.setUser(auth);
}
}
return message;
}
});
}
}
The client passes the token in the STOMP CONNECT headers:
const headers = {
'Authorization': 'Bearer ' + jwtToken
};
stompClient.connect(headers, function(frame) {
console.log('Authenticated and connected: ' + frame);
// Subscribe and send messages as usual
}, function(error) {
console.error('Authentication failed: ' + error);
});
Key security considerations for WebSocket applications:
- Always use
(WebSocket Secure) in production to encrypt traffic with TLS.wss:// - Restrict allowed origins with
to prevent cross-site WebSocket hijacking.setAllowedOrigins() - Validate and sanitize all incoming message payloads to prevent injection attacks.
- Implement rate limiting on WebSocket messages to protect against denial-of-service.
- Set appropriate session timeouts and handle disconnections gracefully.
- Use STOMP-level authorization to control access to specific destinations beyond the handshake.
/ws