This article targets Spring Boot 3.x (Java 17 or later).

Once you’re comfortable with REST development over HTTP APIs, have you ever wondered how to implement real-time features like chat or notifications? That’s where WebSocket comes in.

In Spring Boot, combining STOMP and SockJS lets you implement real-time communication with minimal configuration. This article walks you through building a broadcast-style chat feature from scratch, step by step.

Understanding How WebSocket, STOMP, and SockJS Relate

First, let’s briefly sort out the roles of these three technologies.

  • WebSocket is a transport-layer protocol that enables bidirectional communication. Instead of the HTTP model of “send a request and wait for a response,” the server and client can send messages to each other at any time.
  • STOMP (Simple Text Oriented Messaging Protocol) is a messaging protocol that sits on top of WebSocket and provides a publish/subscribe model. It lets you 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 is unavailable, it automatically switches to HTTP-based alternatives.

Spring Boot supports this entire stack with the single spring-boot-starter-websocket dependency.

Note that if one-way push from the server is all you need, such as notification delivery or progress display, Server-Sent Events (SSE) is a better fit than WebSocket. Because SSE runs over HTTP, it works well with proxies and firewalls and has simpler infrastructure requirements. For chat or collaborative editing, where bidirectional communication is required, choose WebSocket.

Adding the Dependency

For Maven, add the following to pom.xml.

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-websocket</artifactId>
</dependency>

For Gradle, use this.

implementation 'org.springframework.boot:spring-boot-starter-websocket'

Implementing the WebSocket Configuration Class

This is the core configuration class. Enable @EnableWebSocketMessageBroker and configure the endpoint and the broker.

@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {

    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        // クライアントが接続するエンドポイント。withSockJS() でフォールバックを有効化
        registry.addEndpoint("/ws").withSockJS();
    }

    @Override
    public void configureMessageBroker(MessageBrokerRegistry registry) {
        // /app で始まるメッセージはアプリケーションの @MessageMapping に転送
        registry.setApplicationDestinationPrefixes("/app");
        // /topic で始まるメッセージはインメモリブローカーが全購読者に配信
        registry.enableSimpleBroker("/topic");
    }
}

/app is the destination prefix for messages sent from the client to the server, and /topic is the prefix for broadcast destinations. Keeping the difference between these two roles in mind will make the rest of the flow much easier to follow.

Implementing the Message Handler

Now let’s create a controller that receives messages from clients. We’ll define the DTO alongside it.

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")       // /app/chat 宛てのメッセージを受信
    @SendTo("/topic/messages")     // /topic/messages の購読者全員に返信
    public ChatMessage handleMessage(ChatMessage message) {
        return message;
    }
}

You can use @MessageMapping just like HTTP’s @RequestMapping. Spring interprets the /app prefix automatically, so writing just /chat here is enough.

Broadcasting from the Server Side (SimpMessagingTemplate)

One-to-One Messaging to a Specific User (@SendToUser / convertAndSendToUser)

When you want to send a message 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 only to the matching session.

@Controller
public class PrivateChatController {

    @MessageMapping("/private")
    @SendToUser("/queue/messages")  // 送信元ユーザー本人にのみ返信
    public ChatMessage handlePrivate(ChatMessage message, Principal principal) {
        // principal.getName() で認証済みユーザー名が取れる
        message.setSender(principal.getName());
        return message;
    }
}

To send to an arbitrary user from the server side, write the following.

messagingTemplate.convertAndSendToUser(
    "alice",                 // 宛先ユーザー名
    "/queue/notifications",  // ユーザー別キュー
    new ChatMessage("system", "新しい通知があります")
);

On the client side, subscribing to /user/queue/notifications lets a user receive only messages addressed to them. Note that the /user prefix is added automatically by Spring, which is why it’s omitted in the server-side code.

Retrieving the Authenticated User and Message-Level Authorization (Principal / ChannelInterceptor)

Authentication at the HTTP handshake stage is handled by the Spring Security configuration described later, but for per-message authorization such as “only administrators can subscribe to this topic,” use a 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("管理者権限が必要です");
                    }
                }
                return message;
            }
        });
    }
}

The standard pattern is to extract the STOMP command (CONNECT / SUBSCRIBE / SEND) and the destination from StompHeaderAccessor and make the authorization decision based on them. For the upstream step of obtaining the user from a JWT, see How to Implement JWT Authentication in Spring Boot.

Scaling Across Multiple Instances (External Broker)

Because enableSimpleBroker is an in-memory implementation, running your Spring Boot app across multiple Pods means messages are not shared between instances. When deploying 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 handling connection drops during rolling updates on Kubernetes, also see How to Achieve Graceful Shutdown and Zero-Downtime Deployment in Spring Boot.

The @stomp/stompjs client also has a reconnectDelay option, and automatic reconnection at 5-second intervals is enabled by default. Tuning this value for your environment in production helps minimize the impact on users during deployments.

When you want to actively push messages from outside a controller, use SimpMessagingTemplate. It’s handy for periodic notifications or pushes triggered by events.

@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 to convertAndSend is the topic path, and the second is the payload. You can call it from @Scheduled tasks or from other Service classes as well.

Implementing the JavaScript Client

On the frontend, we’ll use SockJS and @stomp/stompjs (v5 or later). You can open the following HTML directly in a browser to verify it works.

<!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="メッセージを入力">
    <button onclick="sendMessage()">送信</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 belongs to the stompjs (v2.x) API. That library has been unmaintained since 2015 and is deprecated, so @stomp/stompjs is now the recommended successor.

Integrating Spring Security with WebSocket

In projects where Spring Security is already installed, the WebSocket endpoint 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()  // WebSocketエンドポイントを許可
                .anyRequest().authenticated()
            )
            .csrf(csrf -> csrf
                .ignoringRequestMatchers("/ws/**")      // WebSocketはCSRF保護の対象外に
            );
        return http.build();
    }
}

WebSocket doesn’t play well with HTTP’s CSRF token mechanism, so the common approach is to disable CSRF protection only for the WebSocket endpoint. If you want to allow only authenticated users to connect, simply change permitAll() to authenticated().

Note that this configuration covers protection at the HTTP handshake level. WebSocket message-level access control, meaning “who can subscribe to or send to which topic,” is out of its scope. For details on authentication configuration combined with JWT, see How to Implement JWT Authentication in Spring Boot.

How to Verify It Works

Once the app is running, open the HTML file in multiple browser tabs. You can confirm that broadcasting works when a message sent from one tab also arrives in the other.

In Chrome DevTools, select the WS filter in the Network tab to inspect the STOMP frames being sent and received. You should see a CONNECT frame go through when the connection is established.

If things aren’t working, check the following.

  • A 403 error almost always means Spring Security is blocking the WebSocket endpoint. Review your permitAll() configuration.
  • If the connection succeeds but messages don’t arrive, a misconfigured /app or /topic prefix is the usual cause. Double-check the send destination and subscription paths.
  • If you get a CORS error, you can add .setAllowedOrigins("*") to registerStompEndpoints, but limit this to development and testing. In production, specify allowed origins explicitly or consider setAllowedOriginPatterns. See the Spring Boot CORS Configuration Guide for details.

Key Points for Production Operation

In production, connecting over wss:// (WebSocket Secure) is a must. As with HTTPS, the common setup is to terminate TLS at the reverse proxy (Nginx / ALB / Ingress) and have Spring Boot receive plain HTTP. If you’re using Nginx, don’t forget to configure proxy_http_version 1.1; and forwarding of the Upgrade / Connection headers.

To adjust the session idle timeout, define a ServletServerContainerFactoryBean as a Bean. The application-layer default is unlimited, but the reverse proxy may time out first, so the standard practice is to tune both the application layer and the proxy layer together.

@Bean
public ServletServerContainerFactoryBean createWebSocketContainer() {
    ServletServerContainerFactoryBean container = new ServletServerContainerFactoryBean();
    container.setMaxSessionIdleTimeout(60 * 60 * 1000L); // 1時間
    return container;
}

For testing, the go-to approach is an integration test that starts the app with @SpringBootTest(webEnvironment = RANDOM_PORT) and uses WebSocketStompClient to make an actual STOMP connection. The typical pattern is to wait for message receipt with a CompletableFuture and call get() with a timeout.

Summary

We’ve walked through implementing real-time communication with Spring Boot + STOMP + SockJS. The basic three-layer structure is: define the endpoint and broker in the configuration class, receive messages with @MessageMapping, and send push notifications with SimpMessagingTemplate.

When you need interceptors for WebSocket request processing, also take a look at The Difference Between Filter and Interceptor and How to Use Them.