Contents
- What Are WebSockets
- Dependencies
- Configuring WebSocket with STOMP
- Message Controller
- Sending Messages from Server
- JavaScript Client
- User-Specific Messages
- Securing WebSocket Endpoints
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 STOMP (Simple Text Oriented Messaging Protocol) as a sub-protocol on top of WebSocket. STOMP provides a frame-based format with commands like CONNECT, SUBSCRIBE, SEND, and MESSAGE, giving you a higher-level messaging abstraction with destination-based routing instead of raw WebSocket frames.
STOMP over WebSocket lets you use familiar messaging patterns (publish-subscribe, point-to-point) without writing low-level WebSocket frame handling code. Spring maps STOMP destinations to @MessageMapping methods just like @RequestMapping maps HTTP URLs to controller methods.
Add the spring-boot-starter-websocket dependency. It pulls in Spring WebSocket, Spring Messaging, and STOMP support. For browser clients that need fallback transports (long-polling, streaming) when WebSocket is unavailable, include SockJS.
<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>
SockJS is a JavaScript library that provides a WebSocket-like API with automatic fallback to HTTP-based transports (XHR streaming, XHR polling, iframe-based transports) when WebSocket is not available. This ensures your application works even behind restrictive proxies or older browsers.
Create a configuration class that implements WebSocketMessageBrokerConfigurer and is annotated with @EnableWebSocketMessageBroker. This sets up the STOMP messaging infrastructure.
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:
- registerStompEndpoints() — defines the HTTP URL where the WebSocket handshake occurs. The withSockJS() call enables fallback transports.
- setApplicationDestinationPrefixes("/app") — messages sent to destinations starting with /app are routed to @MessageMapping methods in your controllers.
- enableSimpleBroker("/topic", "/queue") — activates the built-in message broker. Clients subscribing to /topic/... or /queue/... receive messages relayed by the broker.
The simple in-memory broker is suitable for development and single-instance deployments. For production clusters, use an external STOMP broker such as RabbitMQ or ActiveMQ by calling registry.enableStompBrokerRelay("/topic", "/queue") instead.
Use @MessageMapping to handle STOMP messages sent by clients, similar to how @RequestMapping handles HTTP requests. The @SendTo annotation specifies the broker destination where the return value is published.
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 SEND frame to /app/chat.send.
- Spring strips the /app prefix and routes to @MessageMapping("/chat.send").
- The method processes the message and returns a ChatMessage.
- The return value is sent to /topic/public (via @SendTo), and all subscribers receive it.
You can also use @DestinationVariable to capture path variables from the destination, for example @MessageMapping("/chat.{roomId}") with @DestinationVariable String roomId.
While @SendTo works for request-reply patterns, you often need to push messages from the server without a client request — for example, broadcasting notifications or scheduled updates. Use SimpMessagingTemplate for this.
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 into REST controllers to bridge HTTP and WebSocket. A REST endpoint can trigger a WebSocket broadcast:
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 is auto-configured by Spring Boot when @EnableWebSocketMessageBroker is present. It supports convertAndSend() for broadcasting to a topic and convertAndSendToUser() for targeting a specific authenticated user.
Use SockJS to establish the connection and STOMP.js to handle STOMP framing. The client connects, subscribes to topics, and sends messages.
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:
- new SockJS('/ws') — connects to the STOMP endpoint registered in WebSocketConfig.
- stompClient.subscribe() — registers a callback for messages arriving at the given destination.
- stompClient.send() — sends a STOMP SEND frame. The second argument is headers (empty object here), and the third is the body.
- The /user/queue/private subscription resolves to a user-specific queue thanks to the userDestinationPrefix configured on the server.
Always implement reconnection logic in your client. WebSocket connections can drop due to network changes, server restarts, or load balancer timeouts. SockJS handles transport fallback but does not automatically reconnect after a disconnection.
To send messages to a specific authenticated user rather than broadcasting, use @SendToUser in controllers or convertAndSendToUser() on SimpMessagingTemplate. Spring resolves the user via the Principal associated with the WebSocket session.
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);
});
Under the hood, Spring translates /user/queue/direct to a session-specific destination like /queue/direct-user{sessionId}. This means the user does not need to know their session ID — they just subscribe to /user/queue/direct and Spring routes it correctly. The Principal is typically populated by Spring Security during the WebSocket handshake.
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 for custom authentication logic, such as token-based authentication:
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 wss:// (WebSocket Secure) in production to encrypt traffic with TLS.
- Restrict allowed origins with setAllowedOrigins() to prevent cross-site WebSocket hijacking.
- 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.
The HTTP handshake endpoint (/ws) must be secured at the HTTP layer. STOMP message security only applies after the connection is established. Without HTTP-level authentication, unauthenticated clients can establish WebSocket connections and then be blocked at the message level, wasting server resources.