Contents

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.

FeatureHTTPWebSocket
CommunicationRequest-response (half-duplex)Full-duplex
ConnectionNew connection per request (HTTP/1.1 keep-alive reuses)Single persistent connection
OverheadHeaders sent with every requestMinimal framing after handshake
Server PushNot natively supportedServer can push at any time
ProtocolHTTP/HTTPSws:// or wss://

Common use cases for WebSockets include:

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:

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:

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:

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:

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.