This article targets Spring Boot 3.x (Java 17 or higher).
You’ve gotten comfortable with REST API development over HTTP, but have you ever been puzzled about how to implement real-time features like chat or notifications? That’s where WebSocket comes in.
In Spring Boot, by combining STOMP and SockJS, you can implement real-time communication features with simple configuration. This article walks through the steps to build a broadcast-style chat feature from scratch.
Understanding the Relationship Between WebSocket, STOMP, and SockJS
First, let’s roughly organize the roles of these three technologies.
- WebSocket is a transport-layer protocol that enables bidirectional communication. Unlike HTTP’s “request and wait for response” model, the server and client can send messages to each other at any time.
- STOMP (Simple Text Oriented Messaging Protocol) is a messaging protocol that runs on top of WebSocket, providing a publish/subscribe model. It allows you to organize communication in terms of “subscribe to this topic” and “send a message to this topic.”
- SockJS is a fallback library for browsers and network environments that don’t support WebSocket. When WebSocket can’t be used, it automatically switches to an HTTP-based alternative.
Spring Boot supports this entire stack with the single spring-boot-starter-websocket dependency.
Adding Dependencies
For Maven, add the following to your pom.xml:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
For Gradle:
implementation 'org.springframework.boot:spring-boot-starter-websocket'
Implementing the WebSocket Configuration Class
This class is the heart of the configuration. Enable @EnableWebSocketMessageBroker and configure the endpoint and broker.
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
// The endpoint that clients connect to. withSockJS() enables fallback.
registry.addEndpoint("/ws").withSockJS();
}
@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
// Messages starting with /app are forwarded to the application's @MessageMapping
registry.setApplicationDestinationPrefixes("/app");
// Messages starting with /topic are distributed by the in-memory broker to all subscribers
registry.enableSimpleBroker("/topic");
}
}
/app is the prefix for destinations sent from the client to the server, and /topic is the prefix for broadcast distribution. Once you understand the difference between these two roles, the rest will flow smoothly.
Implementing the Message Reception Handler
Create a controller that receives messages from the client. Let’s also define a DTO.
public class ChatMessage {
private String sender;
private String content;
public String getSender() { return sender; }
public void setSender(String sender) { this.sender = sender; }
public String getContent() { return content; }
public void setContent(String content) { this.content = content; }
}
@Controller
public class ChatController {
@MessageMapping("/chat") // Receives messages addressed to /app/chat
@SendTo("/topic/messages") // Replies to all subscribers of /topic/messages
public ChatMessage handleMessage(ChatMessage message) {
return message;
}
}
@MessageMapping can be used with the same feel as HTTP’s @RequestMapping. Since Spring automatically interprets the /app prefix, you only need to write /chat here.
Server-Side Broadcasting (SimpMessagingTemplate)
One-to-One Messaging to Specific Users (@SendToUser / convertAndSendToUser)
When you want to send messages to a specific user rather than broadcasting, use @SendToUser or SimpMessagingTemplate#convertAndSendToUser. Spring internally manages a unique queue per user name (/user/queue/...) and delivers messages only to the relevant session.
@Controller
public class PrivateChatController {
@MessageMapping("/private")
@SendToUser("/queue/messages") // Replies only to the sending user themselves
public ChatMessage handlePrivate(ChatMessage message, Principal principal) {
// principal.getName() returns the authenticated user name
message.setSender(principal.getName());
return message;
}
}
To send to an arbitrary user from the server side, write it as follows.
messagingTemplate.convertAndSendToUser(
"alice", // Destination user name
"/queue/notifications", // Per-user queue
new ChatMessage("system", "You have a new notification")
);
On the client side, subscribing to /user/queue/notifications will let you receive only messages addressed to yourself. Note that the /user prefix is automatically prepended by Spring, so it’s omitted in the server-side code.
Retrieving the Authenticated User and Message-Level Authorization (Principal / ChannelInterceptor)
Authentication at the HTTP handshake stage can be handled by the Spring Security configuration described above, but for per-message authorization such as “only administrators can subscribe to this topic,” use ChannelInterceptor.
@Configuration
public class WebSocketAuthConfig implements WebSocketMessageBrokerConfigurer {
@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.SUBSCRIBE.equals(accessor.getCommand())) {
String destination = accessor.getDestination();
Principal user = accessor.getUser();
if (destination != null && destination.startsWith("/topic/admin")
&& !hasAdminRole(user)) {
throw new AccessDeniedException("Administrator privileges required");
}
}
return message;
}
});
}
}
The standard pattern is to extract the STOMP command (CONNECT / SUBSCRIBE / SEND) and destination from StompHeaderAccessor and make the authorization decision. For details on the preceding step of retrieving the user via JWT, see How to Implement JWT Authentication in Spring Boot.
Scaling with Multi-Instance Deployments (External Broker)
Since enableSimpleBroker is an in-memory implementation, when you run the Spring Boot application across multiple Pods, messages are not shared between instances. When placing it behind a load balancer in production, use RabbitMQ or ActiveMQ as a relay.
@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
registry.setApplicationDestinationPrefixes("/app");
registry.enableStompBrokerRelay("/topic", "/queue")
.setRelayHost("rabbitmq.internal")
.setRelayPort(61613)
.setClientLogin("guest")
.setClientPasscode("guest");
}
On the RabbitMQ side, you need to enable the rabbitmq_stomp plugin. For measures against connection disruptions during rolling updates when operating on Kubernetes, also refer to How to Achieve Graceful Shutdown and Zero-Downtime Deployment in Spring Boot.
Additionally, the client @stomp/stompjs has a reconnectDelay option, and automatic reconnection at 5-second intervals is enabled by default. In production, adjusting this value to suit your environment minimizes user impact during deployments.
When you want to proactively push messages from outside the controller, use SimpMessagingTemplate. It’s convenient for periodic notifications or pushes when events occur.
@Service
public class NotificationService {
private final SimpMessagingTemplate messagingTemplate;
public NotificationService(SimpMessagingTemplate messagingTemplate) {
this.messagingTemplate = messagingTemplate;
}
public void sendNotification(String message) {
messagingTemplate.convertAndSend("/topic/notifications", message);
}
}
The first argument of convertAndSend is the topic path, and the second is the payload. It can also be called from @Scheduled tasks or other Service classes.
Implementing the JavaScript Client
On the frontend, use SockJS and @stomp/stompjs (v5 or later). You can verify the operation by opening the following HTML directly in a browser.
<!DOCTYPE html>
<html>
<head>
<title>WebSocket Chat</title>
<script src="https://cdn.jsdelivr.net/npm/sockjs-client/dist/sockjs.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@stomp/stompjs/bundles/stomp.umd.min.js"></script>
</head>
<body>
<input id="message" placeholder="Enter a message">
<button onclick="sendMessage()">Send</button>
<div id="output"></div>
<script>
const client = new StompJs.Client({
webSocketFactory: () => new SockJS('/ws')
});
client.onConnect = () => {
client.subscribe('/topic/messages', (msg) => {
const body = JSON.parse(msg.body);
document.getElementById('output').innerHTML +=
`<p>${body.sender}: ${body.content}</p>`;
});
};
client.activate();
function sendMessage() {
const content = document.getElementById('message').value;
client.publish({
destination: '/app/chat',
body: JSON.stringify({ sender: 'user1', content: content })
});
}
</script>
</body>
</html>
The older Stomp.over(socket) pattern is the API of stompjs (v2.x). Maintenance stopped after 2015 and it has been deprecated, so @stomp/stompjs is now recommended as its successor.
Integrating Spring Security with WebSocket
In projects where Spring Security is already installed, WebSocket endpoints may be blocked with a 403. The following configuration resolves this.
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/ws/**").permitAll() // Allow WebSocket endpoints
.anyRequest().authenticated()
)
.csrf(csrf -> csrf
.ignoringRequestMatchers("/ws/**") // Exclude WebSocket from CSRF protection
);
return http.build();
}
}
Since WebSocket doesn’t pair well with HTTP’s CSRF token mechanism, disabling it only for WebSocket endpoints is the common workaround. If you want only authenticated users to connect, just change permitAll() to authenticated().
Note that this configuration covers protection at the HTTP handshake level. Access control at the WebSocket message level — “who can subscribe to or send to which topic” — is out of scope. For details on authentication setup combined with JWT, see How to Implement JWT Authentication in Spring Boot.
Verifying the Operation
Once the app is running, try opening the HTML file in multiple browser tabs. When a message sent in one tab arrives in the other, you can confirm broadcasting is working.
In Chrome DevTools, selecting the WS filter in the Network tab lets you observe the STOMP frames being sent and received. You should see the CONNECT frame flowing on connection.
If it doesn’t work, check the following.
- A 403 error is most often caused by Spring Security blocking the WebSocket endpoint. Review your
permitAll()configuration. - If the connection succeeds but messages don’t arrive, misconfiguration of the
/appand/topicprefixes is common. Recheck the destination and subscription paths. - If a CORS error appears, you can add
.setAllowedOrigins("*")toregisterStompEndpoints, but limit this to development and testing purposes. In production, explicitly specify allowed origins or considersetAllowedOriginPatterns. For details, see the Spring Boot CORS Configuration Guide.
Summary
We’ve walked through the flow of implementing real-time communication with Spring Boot + STOMP + SockJS. The basic structure is a three-layer model: define the endpoint and broker in the configuration class, receive messages with @MessageMapping, and send push notifications with SimpMessagingTemplate.
In scenarios where you want to use interceptors for WebSocket request processing, also refer to The Difference Between Filter and Interceptor and How to Use Them.
Frequently Asked Questions (FAQ)
Q. How should I choose between WebSocket and Server-Sent Events (SSE)?
WebSocket is suitable for chats and collaborative editing that require bidirectional communication, while SSE is suitable for unidirectional pushes from the server (notification delivery, progress display, stock tickers, etc.). Since SSE runs on top of HTTP, it has good compatibility with proxies and firewalls, and its infrastructure requirements are simpler. SSE implementation is covered in a separate article.
Q. Is it necessary to use WSS (WebSocket Secure) in production?
Yes. Connecting via wss:// is mandatory in production. Since TLS termination is performed similarly to HTTPS, the typical configuration handles certificates on the reverse proxy side (Nginx / ALB / Ingress) and receives traffic with regular HTTP on the Spring Boot side. When using Nginx, don’t forget to include the proxy_http_version 1.1; and Upgrade / Connection header relay settings.
Q. How do I extend the WebSocket session idle timeout?
Define ServletServerContainerFactoryBean as a Bean and configure setMaxSessionIdleTimeout. The default is unlimited, but since there are cases where the reverse proxy times out, the standard approach is to adjust both the application layer and proxy layer together.
@Bean
public ServletServerContainerFactoryBean createWebSocketContainer() {
ServletServerContainerFactoryBean container = new ServletServerContainerFactoryBean();
container.setMaxSessionIdleTimeout(60 * 60 * 1000L); // 1 hour
return container;
}
Q. How should I write tests?
A common approach is integration tests that actually establish STOMP connections using WebSocketStompClient after starting with @SpringBootTest(webEnvironment = RANDOM_PORT). The typical style is to wait for message reception with CompletableFuture and call get() with a timeout.