Java WebSocket with Spring
Spring WebSocket is the WebSocket module built into the Spring Framework, giving you a high-level API for handling real-time, bidirectional communication in Java applications. It ships with support for raw WebSocket connections, the STOMP messaging sub-protocol, SockJS fallback for older browsers, and tight integration with Spring Security. If you are already building on Spring Boot, this Spring Boot WebSocket implementation is the most natural way to add WebSocket support to your application.
Project Setup
Start with a Spring Boot 3.x project. The easiest way is to generate one from start.spring.io with the “WebSocket” dependency selected. If you are adding WebSocket support to an existing project, add the starter 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'
This pulls in everything you need: the Spring WebSocket module, the Tomcat WebSocket runtime, and the STOMP messaging libraries. No additional servlet container configuration is required because Spring Boot auto-configures the embedded server.
If you plan to use Spring Security with your WebSocket endpoints (covered later in this guide), also include:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
Raw WebSocket Handler
The simplest way to handle WebSocket connections is to extend TextWebSocketHandler. This gives you direct control over the connection lifecycle and incoming messages without any sub-protocol.
package com.example.ws;
import org.springframework.web.socket.CloseStatus;
import org.springframework.web.socket.TextMessage;
import org.springframework.web.socket.WebSocketSession;
import org.springframework.web.socket.handler.TextWebSocketHandler;
import java.io.IOException;
import java.util.concurrent.CopyOnWriteArraySet;
public class EchoWebSocketHandler extends TextWebSocketHandler {
private final CopyOnWriteArraySet<WebSocketSession> sessions = new CopyOnWriteArraySet<>();
@Override
public void afterConnectionEstablished(WebSocketSession session) {
sessions.add(session);
System.out.println("Connected: " + session.getId());
}
@Override
protected void handleTextMessage(WebSocketSession session, TextMessage message) throws IOException {
String payload = message.getPayload();
// Echo the message back to the sender
session.sendMessage(new TextMessage("Echo: " + payload));
}
@Override
public void afterConnectionClosed(WebSocketSession session, CloseStatus status) {
sessions.remove(session);
System.out.println("Disconnected: " + session.getId() + " (" + status + ")");
}
@Override
public void handleTransportError(WebSocketSession session, Throwable exception) {
System.err.println("Transport error for session " + session.getId() + ": " + exception.getMessage());
sessions.remove(session);
}
}
Register the handler in a configuration class:
package com.example.ws;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.socket.config.annotation.EnableWebSocket;
import org.springframework.web.socket.config.annotation.WebSocketConfigurer;
import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry;
@Configuration
@EnableWebSocket
public class WebSocketConfig implements WebSocketConfigurer {
@Override
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
registry.addHandler(new EchoWebSocketHandler(), "/ws/echo")
.setAllowedOrigins("*");
}
}
After starting the application, you can connect to ws://localhost:8080/ws/echo with any WebSocket client. You can test this quickly with the WebSocket tester at tests.ws.
When to Use Raw Handlers
Raw WebSocket handlers are a good fit when you need full control over the wire format, when you are implementing a custom protocol, or when the overhead of STOMP is not justified. For most application-level messaging, STOMP provides better structure.
STOMP over WebSocket
STOMP (Simple Text Oriented Messaging Protocol) adds a message-routing layer on top of WebSocket. Instead of handling raw text frames, you define message destinations and Spring routes incoming messages to annotated controller methods, similar to how @RequestMapping works for HTTP.
Configuring the Message Broker
package com.example.ws;
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 StompWebSocketConfig implements WebSocketMessageBrokerConfigurer {
@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
// Messages with these prefixes are routed to the broker
registry.enableSimpleBroker("/topic", "/queue");
// Messages with this prefix are routed to @MessageMapping methods
registry.setApplicationDestinationPrefixes("/app");
// Prefix for user-specific destinations
registry.setUserDestinationPrefix("/user");
}
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/ws/stomp")
.setAllowedOriginPatterns("*")
.withSockJS();
}
}
The simple broker keeps subscriptions in memory, which is fine for a single server instance. For clustered deployments, you would replace it with an external broker like RabbitMQ or ActiveMQ by using registry.enableStompBrokerRelay(...) instead.
Message Controller
package com.example.ws;
import org.springframework.messaging.handler.annotation.MessageMapping;
import org.springframework.messaging.handler.annotation.SendTo;
import org.springframework.stereotype.Controller;
@Controller
public class ChatController {
@MessageMapping("/chat.send")
@SendTo("/topic/messages")
public ChatMessage handleMessage(ChatMessage message) {
// Any processing logic here
return message;
}
}
package com.example.ws;
public class ChatMessage {
private String sender;
private String content;
private String timestamp;
// Default constructor required for JSON deserialization
public ChatMessage() {}
public ChatMessage(String sender, String content, String timestamp) {
this.sender = sender;
this.content = content;
this.timestamp = timestamp;
}
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; }
public String getTimestamp() { return timestamp; }
public void setTimestamp(String timestamp) { this.timestamp = timestamp; }
}
When a client sends a STOMP message to /app/chat.send, Spring deserializes the JSON payload into a ChatMessage, passes it to handleMessage, and broadcasts the return value to all subscribers of /topic/messages.
Sending Messages to Specific Users
Spring’s SimpMessagingTemplate allows you to push messages to individual users. This relies on a user identity being available in the WebSocket session, typically set during WebSocket authentication.
package com.example.ws;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.stereotype.Service;
@Service
public class NotificationService {
private final SimpMessagingTemplate messagingTemplate;
public NotificationService(SimpMessagingTemplate messagingTemplate) {
this.messagingTemplate = messagingTemplate;
}
public void sendToUser(String username, String destination, Object payload) {
messagingTemplate.convertAndSendToUser(username, destination, payload);
}
public void notifyUser(String username, String message) {
Notification notification = new Notification("system", message);
messagingTemplate.convertAndSendToUser(username, "/queue/notifications", notification);
}
}
On the client side, the user subscribes to /user/queue/notifications. Spring resolves the /user prefix to the authenticated principal automatically.
Broadcasting to Topics
Broadcasting from anywhere in your application code follows the same pattern using SimpMessagingTemplate:
@Service
public class LiveScoreService {
private final SimpMessagingTemplate messagingTemplate;
public LiveScoreService(SimpMessagingTemplate messagingTemplate) {
this.messagingTemplate = messagingTemplate;
}
public void broadcastScoreUpdate(String matchId, ScoreUpdate update) {
messagingTemplate.convertAndSend("/topic/matches/" + matchId, update);
}
}
All clients subscribed to /topic/matches/123 will receive the update. This is useful when server-side events (database changes, scheduled tasks, external API callbacks) need to push data to connected clients.
SockJS Fallback
Not all networks allow WebSocket connections. Corporate proxies and certain firewalls can block the upgrade handshake. SockJS provides automatic fallback transport mechanisms including XHR streaming, XHR polling, and EventSource. You already saw the configuration: appending .withSockJS() to the endpoint registration is all it takes on the server side.
On the client, use the SockJS library instead of the native WebSocket API:
import SockJS from 'sockjs-client';
import { Client } from '@stomp/stompjs';
const client = new Client({
webSocketFactory: () => new SockJS('http://localhost:8080/ws/stomp'),
onConnect: () => {
client.subscribe('/topic/messages', (message) => {
const body = JSON.parse(message.body);
console.log('Received:', body);
});
},
reconnectDelay: 5000,
});
client.activate();
SockJS adds a small overhead compared to plain WebSocket, but it ensures connectivity in restrictive environments. For more on choosing transports and fallbacks, see WebSocket vs SSE.
Authentication and Security
Spring Security integrates with WebSocket endpoints at two levels: the initial HTTP handshake and the STOMP message layer.
Handshake-Level Security
The WebSocket handshake is a regular HTTP upgrade request. Your existing Spring Security configuration (session cookies, OAuth2 bearer tokens) applies here. You can restrict access to the WebSocket endpoint in your SecurityFilterChain:
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/ws/**").authenticated()
.anyRequest().permitAll()
)
.formLogin(Customizer.withDefaults());
return http.build();
}
STOMP Message-Level Security
For finer control, configure authorization rules on STOMP destinations:
package com.example.ws;
import org.springframework.context.annotation.Configuration;
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
.simpDestMatchers("/app/**").authenticated()
.simpSubscribeDestMatchers("/topic/public/**").permitAll()
.simpSubscribeDestMatchers("/topic/admin/**").hasRole("ADMIN")
.simpSubscribeDestMatchers("/user/**").authenticated()
.anyMessage().denyAll();
}
@Override
protected boolean sameOriginDisabled() {
// Disable CSRF for WebSocket if you handle CORS yourself
return true;
}
}
This allows you to enforce role-based access on specific destinations. An unauthenticated client can subscribe to public topics, but attempting to subscribe to /topic/admin/dashboard without the ADMIN role results in an access denied error.
Passing Authentication to STOMP Sessions
If you are using token-based authentication, you can intercept the STOMP CONNECT frame to extract and validate the token:
@Configuration
public class StompWebSocketConfig implements WebSocketMessageBrokerConfigurer {
@Override
public void configureClientInboundChannel(ChannelRegistration registration) {
registration.interceptors(new ChannelInterceptor() {
@Override
public Message<?> preSend(Message<?> message, MessageChannel channel) {
StompHeaderAccessor accessor = StompHeaderAccessor.wrap(message);
if (StompCommand.CONNECT.equals(accessor.getCommand())) {
String token = accessor.getFirstNativeHeader("Authorization");
// Validate token and set the user principal
if (token != null) {
Authentication auth = validateToken(token);
accessor.setUser(auth);
}
}
return message;
}
});
}
}
Error Handling
Handler-Level Errors
In raw WebSocket handlers, the handleTransportError method catches transport-level issues. For application-level errors, wrap your logic in try-catch blocks inside handleTextMessage.
STOMP Error Handling
For STOMP messaging, use @MessageExceptionHandler in your controller, similar to @ExceptionHandler for REST controllers:
@Controller
public class ChatController {
@MessageMapping("/chat.send")
@SendTo("/topic/messages")
public ChatMessage handleMessage(ChatMessage message) {
if (message.getContent() == null || message.getContent().isBlank()) {
throw new IllegalArgumentException("Message content cannot be empty");
}
return message;
}
@MessageExceptionHandler
@SendToUser("/queue/errors")
public String handleException(IllegalArgumentException ex) {
return ex.getMessage();
}
}
When handleMessage throws, the error handler catches it and sends the error message to the user’s private error queue. The client subscribes to /user/queue/errors to receive these.
Disconnect Handling
Tracking client disconnections is important for cleanup. Register a SessionDisconnectEvent listener:
@Component
public class WebSocketEventListener {
@EventListener
public void handleDisconnect(SessionDisconnectEvent event) {
StompHeaderAccessor accessor = StompHeaderAccessor.wrap(event.getMessage());
String sessionId = accessor.getSessionId();
String username = Optional.ofNullable(accessor.getUser())
.map(Principal::getName)
.orElse("anonymous");
System.out.println("User disconnected: " + username + " (session: " + sessionId + ")");
}
}
Production Configuration
Running WebSocket in production requires tuning several settings beyond the defaults.
Thread Pool Configuration
The inbound and outbound message channels use thread pools. Under high load, the defaults may not be sufficient:
@Override
public void configureClientInboundChannel(ChannelRegistration registration) {
registration.taskExecutor()
.corePoolSize(8)
.maxPoolSize(32)
.queueCapacity(100);
}
@Override
public void configureClientOutboundChannel(ChannelRegistration registration) {
registration.taskExecutor()
.corePoolSize(8)
.maxPoolSize(32);
}
Size these based on your expected concurrent connection count and message throughput. Monitor the queue depth in production to detect backpressure.
Message Size Limits
Protect against oversized messages by setting limits in the WebSocket transport configuration:
@Override
public void configureWebSocketTransport(WebSocketTransportRegistration registration) {
registration.setMessageSizeLimit(64 * 1024); // 64 KB max message size
registration.setSendBufferSizeLimit(512 * 1024); // 512 KB send buffer
registration.setSendTimeLimit(20 * 1000); // 20 seconds send timeout
}
Without these limits, a single client sending huge payloads could exhaust server memory.
Heartbeat and Timeouts
STOMP heartbeats keep connections alive and detect stale sessions:
@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
registry.enableSimpleBroker("/topic", "/queue")
.setHeartbeatValue(new long[]{10000, 10000}) // server, client intervals in ms
.setTaskScheduler(heartbeatScheduler());
}
@Bean
public TaskScheduler heartbeatScheduler() {
ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler();
scheduler.setPoolSize(1);
scheduler.setThreadNamePrefix("ws-heartbeat-");
scheduler.initialize();
return scheduler;
}
The first value (10000) tells the server to send heartbeat frames every 10 seconds. The second value is the expected interval from the client. If no data arrives within the expected window, the server can close the dead connection.
External Broker for Clustering
If you run multiple application instances behind a load balancer, the in-memory simple broker will not share subscriptions across nodes. Switch to a full-featured STOMP broker relay:
@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
registry.enableStompBrokerRelay("/topic", "/queue")
.setRelayHost("rabbitmq.internal")
.setRelayPort(61613)
.setClientLogin("guest")
.setClientPasscode("guest")
.setSystemHeartbeatReceiveInterval(10000)
.setSystemHeartbeatSendInterval(10000);
registry.setApplicationDestinationPrefixes("/app");
}
This offloads subscription management and message routing to RabbitMQ (with its STOMP plugin) or ActiveMQ. Each Spring instance connects to the broker as a relay, so a message published on one node reaches subscribers connected to another node.
Alternatives
Spring WebSocket is not the only way to handle WebSocket in Java.
Jakarta WebSocket API (JSR 356) is the standard Java EE / Jakarta EE API. It uses @ServerEndpoint annotations and works in any compliant servlet container. It is lower-level than Spring WebSocket and does not include a message broker or integration with Spring’s dependency injection out of the box. If you are building outside the Spring ecosystem, this is the standard choice.
Netty is an asynchronous networking framework that gives you full control over the event loop and I/O pipeline. It is commonly used for high-throughput WebSocket servers that need to handle tens of thousands of concurrent connections with minimal overhead. Libraries like Vert.x build on Netty and provide a higher-level API.
Micronaut and Quarkus both offer their own WebSocket support with annotations similar to Jakarta WebSocket but tighter integration with their respective frameworks. If startup time and memory footprint are priorities (for example, in serverless or containerized environments), these are worth evaluating.
For a broader comparison of protocols and when plain WebSocket is or is not the right choice, see WebSocket vs HTTP.
Frequently Asked Questions
How do I scale Spring WebSocket across multiple server instances?
The in-memory simple broker only works on a single node. For horizontal scaling, replace it with enableStompBrokerRelay connected to RabbitMQ (using the STOMP plugin on port 61613) or ActiveMQ. This ensures that subscriptions and messages are shared across all application instances. You will also need sticky sessions or a load balancer that supports WebSocket upgrade forwarding.
Can I use Spring WebSocket without STOMP?
Yes. The raw TextWebSocketHandler approach described earlier does not require STOMP at all. You register handlers directly with WebSocketConfigurer and handle incoming messages as plain text or binary frames. This is suitable when you need a custom protocol or when the STOMP overhead is unnecessary for your use case.
How do I handle reconnection on the client side?
Spring’s server-side module does not manage client reconnection. On the client, libraries like @stomp/stompjs have built-in reconnection support via the reconnectDelay option. For raw WebSocket connections, implement retry logic with exponential backoff in your JavaScript code. SockJS also helps by transparently switching to polling transports if the WebSocket connection drops. You can test reconnection behavior against a WebSocket echo server to verify your logic works correctly.
What is the maximum number of concurrent WebSocket connections Spring can handle?
There is no hard limit imposed by Spring itself. The practical limit depends on your JVM heap size, the thread pool configuration for message channels, and the operating system’s file descriptor limit. A well-tuned Spring Boot application on modern hardware can handle tens of thousands of concurrent connections. Monitor heap usage, GC pauses, and thread pool saturation to find the ceiling for your specific workload.