Spring Boot WebSocket: STOMP, Raw Handlers, Scaling
Spring Boot gives you two ways to handle WebSockets: a raw
WebSocketHandler that gives you direct control over frames, and
STOMP over WebSocket that adds a messaging layer with topic
routing. Most tutorials jump straight to STOMP because it is
Spring’s default recommendation. That is not always the right
call.
Two approaches, different trade-offs
Section titled “Two approaches, different trade-offs”Raw WebSocketHandler maps a handler to a URL path. You receive text or binary frames, you send frames back. No protocol on top, no abstraction layer. You control serialization, routing, and session management yourself.
STOMP over WebSocket layers the STOMP messaging protocol on
top of the WebSocket connection. Spring gives you
@MessageMapping annotations, a SimpMessagingTemplate for
sending, and topic/queue destination routing. It feels like
writing a REST controller, but for WebSocket messages.
The trade-off: STOMP adds a framing protocol, destination parsing, and an in-memory message broker. For a chat app with rooms and broadcast, that structure saves you weeks. For a binary streaming service or a protocol where you already define the message format, STOMP is overhead with no benefit.
My recommendation: Start with raw WebSocketHandler unless
you specifically need pub/sub topics or multi-instance message
fan-out through a broker relay. You can always add STOMP later.
Going the other direction — stripping STOMP out — is painful.
Raw WebSocket: WebSocketConfigurer
Section titled “Raw WebSocket: WebSocketConfigurer”Register a handler, set allowed origins, done. This is the minimal setup:
@Configuration@EnableWebSocketpublic class WsConfig implements WebSocketConfigurer {
@Override public void registerWebSocketHandlers( WebSocketHandlerRegistry registry) { registry.addHandler(new MyHandler(), "/ws") .setAllowedOrigins("https://yourdomain.com"); }}Never use setAllowedOrigins("*") in production. It disables
CORS protection entirely. List your actual domains.
The handler itself:
public class MyHandler extends TextWebSocketHandler {
private final Set<WebSocketSession> sessions = ConcurrentHashMap.newKeySet();
@Override public void afterConnectionEstablished( WebSocketSession session) { sessions.add(session); }
@Override protected void handleTextMessage( WebSocketSession session, TextMessage message) throws Exception { String payload = message.getPayload(); // Process and respond session.sendMessage( new TextMessage("echo: " + payload)); }
@Override public void afterConnectionClosed( WebSocketSession session, CloseStatus status) { sessions.remove(session); }}Two things to notice: the sessions set uses
ConcurrentHashMap.newKeySet() because handler methods are
called from different threads. And afterConnectionClosed always
fires, even on abnormal closure, so cleanup is reliable.
STOMP: @MessageMapping and SimpMessagingTemplate
Section titled “STOMP: @MessageMapping and SimpMessagingTemplate”STOMP is Spring’s answer to “I want pub/sub over WebSocket without building a message router.” Enable it:
@Configuration@EnableWebSocketMessageBrokerpublic class StompConfig implements WebSocketMessageBrokerConfigurer {
@Override public void configureMessageBroker( MessageBrokerRegistry config) { config.enableSimpleBroker("/topic", "/queue"); config.setApplicationDestinationPrefixes("/app"); }
@Override public void registerStompEndpoints( StompEndpointRegistry registry) { registry.addEndpoint("/ws-stomp") .setAllowedOrigins("https://yourdomain.com"); }}The enableSimpleBroker line creates an in-memory broker that
routes messages to subscribers. /topic is for broadcast (one
sender, many receivers). /queue is for point-to-point. The
/app prefix routes messages to your @MessageMapping methods
first, so you can process before forwarding.
Handle incoming messages like REST controllers:
@Controllerpublic class ChatController {
private final SimpMessagingTemplate messaging;
public ChatController(SimpMessagingTemplate messaging) { this.messaging = messaging; }
@MessageMapping("/chat.send") @SendTo("/topic/messages") public ChatMessage send(ChatMessage message) { return message; // Broadcast to /topic/messages }
// Send to specific user from anywhere public void notifyUser(String userId, Object payload) { messaging.convertAndSendToUser( userId, "/queue/notifications", payload); }}The common mistake here: forgetting @EnableWebSocketMessageBroker
on the config class and wondering why @MessageMapping methods
never fire. Spring silently ignores them without the annotation.
SockJS fallback: do you still need it?
Section titled “SockJS fallback: do you still need it?”In 2026, every modern browser supports WebSockets natively. The WebSocket protocol has been universally supported since 2012. So why does Spring still offer SockJS?
Corporate proxies. Some enterprise HTTP proxies strip the
Upgrade header, killing the WebSocket handshake. The connection
falls back to HTTP long-polling through SockJS transparently. If
your users include enterprise employees behind corporate firewalls,
enable it:
registry.addEndpoint("/ws-stomp") .setAllowedOrigins("https://yourdomain.com") .withSockJS();If your users are on modern networks — consumer apps, mobile, internal tools on a network you control — skip SockJS. It adds a JavaScript client library (~50 KB), complicates debugging (you cannot tell if the connection is WebSocket or polling without checking), and introduces its own session timeout behavior.
Security: authenticate at the handshake
Section titled “Security: authenticate at the handshake”WebSocket security in Spring Boot comes down to one principle: authenticate during the HTTP upgrade, before the connection opens.
A HandshakeInterceptor runs during the upgrade request, where
you still have access to HTTP headers and cookies:
public class AuthHandshakeInterceptor implements HandshakeInterceptor {
@Override public boolean beforeHandshake( ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler handler, Map<String, Object> attrs) { String token = extractToken(request); if (token == null || !validateJwt(token)) { response.setStatusCode(HttpStatus.FORBIDDEN); return false; } attrs.put("userId", extractUserId(token)); return true; }
@Override public void afterHandshake( ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler handler, Exception ex) {}}Register it on your handler:
registry.addHandler(handler, "/ws") .addInterceptors(new AuthHandshakeInterceptor()) .setAllowedOrigins("https://yourdomain.com");For STOMP, Spring Security’s @MessageMapping security works
too, but it validates after the connection is open. That means
unauthenticated clients hold a connection and consume resources
until the first message. Validate at the handshake to reject
early.
Session handling: WebSocket vs HTTP
Section titled “Session handling: WebSocket vs HTTP”WebSocket sessions and HTTP sessions are separate objects.
Spring creates an HttpSession during the upgrade and passes
its attributes into the WebSocketSession attributes map. After
the upgrade, the HttpSession may expire based on its own
timeout while the WebSocket connection stays alive.
This causes a subtle bug: if your application reads from the
HttpSession during WebSocket message handling, it will get
stale or null data after the HTTP session expires. Store
everything you need in the WebSocketSession attributes during
the handshake. Do not reach back to the HTTP session.
Scaling: STOMP broker relay
Section titled “Scaling: STOMP broker relay”The in-memory simple broker works on a single instance. The moment you deploy two instances behind a load balancer, messages sent on server A never reach clients connected to server B.
The fix is STOMP broker relay. Spring forwards STOMP messages to an external message broker (RabbitMQ or ActiveMQ) that handles fan-out across all instances:
@Overridepublic void configureMessageBroker( MessageBrokerRegistry config) { config.enableStompBrokerRelay("/topic", "/queue") .setRelayHost("rabbitmq.internal") .setRelayPort(61613) .setClientLogin("guest") .setClientPasscode("guest"); config.setApplicationDestinationPrefixes("/app");}This switches from enableSimpleBroker to
enableStompBrokerRelay. RabbitMQ needs the STOMP plugin
enabled (rabbitmq-plugins enable rabbitmq_stomp). ActiveMQ
supports STOMP natively.
The trade-off: you now depend on an external broker. If RabbitMQ goes down, message routing stops. Run your broker in a cluster, monitor the STOMP relay connection, and handle reconnection to the broker (Spring does this automatically with configurable retry).
If you are not using STOMP, scaling raw WebSocket handlers requires your own solution — Redis Pub/Sub, a shared message queue, or a service like Ably’s Pub/Sub Messaging that handles fan-out and connection management across regions. Competitors like Pusher and PubNub offer similar managed messaging, though with different protocol and scaling approaches.
Virtual threads (Java 21+)
Section titled “Virtual threads (Java 21+)”Virtual threads change the economics of WebSocket handling in Spring Boot 3.2+. One property:
spring.threads.virtual.enabled=trueBefore virtual threads, each WebSocket connection consumed a platform thread from Tomcat’s pool (default 200). At 200 concurrent connections, the pool is full. New connections queue. You either increase the pool (more memory — each thread uses ~1 MB of stack) or rewrite handlers to be fully async.
Virtual threads cost a few KB each. A single server can hold tens of thousands of concurrent WebSocket connections without thread pool tuning. Blocking in message handlers — a database query, an HTTP call to another service — is no longer a throughput problem because the virtual thread yields its carrier thread during blocking operations.
The caveat: synchronized blocks pin virtual threads to their
carrier thread. If your handler code or a library you use has
contended synchronized blocks, you lose the benefits. Replace
synchronized with ReentrantLock in hot paths. Spring Boot
3.2+ and most Spring libraries have already made this change
internally.
Connection limits and thread pool sizing
Section titled “Connection limits and thread pool sizing”Without virtual threads, you need to size thread pools:
- Tomcat’s
maxThreads(default 200): the ceiling for concurrent WebSocket connections plus HTTP requests. Increase it for WebSocket-heavy workloads, but each thread costs ~1 MB. maxConnections(default 8192 with NIO): the total connections Tomcat accepts. This is separate from threads — NIO multiplexes connections across fewer threads, but message handling still dispatches to the thread pool.- Send buffer size:
WebSocketSession.setTextMessageSizeLimitandsetBinaryMessageSizeLimitcontrol max frame sizes. Defaults are 64 KB. Large messages fragment into multiple frames.
With virtual threads, ignore maxThreads entirely. Set
maxConnections to your target concurrency and monitor memory
instead of thread counts.
Common mistakes
Section titled “Common mistakes”Blocking in WebSocket handlers. Without virtual threads,
calling a database or external API inside handleTextMessage
blocks a platform thread. Under load, threads exhaust, and the
server stops accepting connections. Either use virtual threads,
offload to a separate @Async executor, or go fully reactive
with WebFlux.
Missing @EnableWebSocketMessageBroker. You add
@MessageMapping controllers but messages never arrive. Without
the broker annotation, Spring does not set up the STOMP
infrastructure. No error, no warning — it just silently does
nothing.
Using setAllowedOrigins("*"). Every tutorial does this for
simplicity. In production, it means any website can open a
WebSocket to your server and send authenticated requests using
your users’ cookies. List specific origins.
Ignoring session cleanup. If afterConnectionClosed throws
an exception, your session tracking leaks. Wrap cleanup in
try/catch. Also handle handleTransportError — it fires on
network errors before the close frame arrives.
Broadcasting with a simple broker across instances. The in-memory broker only knows about connections on the local JVM. If you deploy two instances and wonder why half your users miss messages, this is why. Switch to broker relay or externalize message routing.
Deployment: Tomcat vs Netty
Section titled “Deployment: Tomcat vs Netty”Spring Boot’s default embedded server is Tomcat, which handles WebSockets through its NIO connector. This works well for most applications. The alternative is Netty via Spring WebFlux, which is fully non-blocking and handles more connections per server at the cost of a different programming model.
Use Tomcat (default) when:
- Your app is mostly traditional Spring MVC with some WebSocket endpoints
- You are on Java 21+ with virtual threads (Tomcat + virtual threads matches Netty’s concurrency without rewriting code)
- Your team knows servlet-based Spring
Use Netty (WebFlux) when:
- Your entire application is reactive
- You need the absolute maximum connections per instance
- You are already using
MonoandFluxthroughout
For cloud deployment, two things matter: sticky sessions and
connection draining. Load balancers must route all requests from
the same client to the same server instance (sticky sessions or
session affinity). During deploys, drain WebSocket connections
gracefully — send a close frame, wait for clients to reconnect,
then shut down the instance. Kubernetes preStop hooks with a
grace period handle this:
lifecycle: preStop: exec: command: ["sh", "-c", "sleep 15"]terminationGracePeriodSeconds: 30The sleep 15 gives the load balancer time to stop routing new
connections while existing connections close naturally.
Frequently Asked Questions
Section titled “Frequently Asked Questions”Should I use STOMP or raw WebSocket in Spring Boot?
Section titled “Should I use STOMP or raw WebSocket in Spring Boot?”Raw WebSocketHandler gives you a bidirectional byte pipe with
no protocol overhead. You parse messages, route them, and manage
subscriptions yourself. This is the right choice for binary
protocols, custom message formats, or applications where you
want full control.
STOMP adds a messaging layer: destinations (/topic/chat,
/queue/notifications), message types (SUBSCRIBE, SEND,
MESSAGE), and a header format. Spring maps this to
@MessageMapping methods that feel like REST controllers.
The real win is broker relay — STOMP lets you plug in
RabbitMQ and scale to multiple instances without building
your own message fan-out.
If you are building a single-instance prototype, raw WebSocket is simpler. If you are building a multi-instance production system with pub/sub, STOMP saves significant work.
Do I still need SockJS fallback in 2026?
Section titled “Do I still need SockJS fallback in 2026?”For consumer-facing applications, no. WebSocket support in browsers has been universal since IE10 in 2012. Mobile browsers, Node.js, and every modern HTTP client support the upgrade handshake.
The remaining edge case: corporate networks running
HTTP-inspecting proxies that intercept and strip Upgrade
headers. If your application targets enterprise users behind
such proxies, SockJS provides transparent fallback to HTTP
long-polling. Test by deploying behind your customers’ network
before deciding.
How do I scale Spring Boot WebSockets across servers?
Section titled “How do I scale Spring Boot WebSockets across servers?”The in-memory simple broker only knows about local connections.
Switch to enableStompBrokerRelay with RabbitMQ or ActiveMQ.
Spring forwards all STOMP messages to the external broker,
which routes them to every connected instance. Each instance
maintains a STOMP connection to the broker and receives
messages for its local subscribers.
For raw WebSocket (non-STOMP), you need your own pub/sub layer. Redis Pub/Sub is the most common choice. Publish messages to a Redis channel, subscribe from each server instance, and forward to local WebSocket sessions.
How do virtual threads improve WebSocket handling?
Section titled “How do virtual threads improve WebSocket handling?”Traditional thread-per-connection models hit a wall at a few hundred connections because each platform thread reserves ~1 MB of stack memory. Virtual threads (Java 21+) use a few KB each and yield their carrier thread during blocking I/O.
In practice: set spring.threads.virtual.enabled=true in
Spring Boot 3.2+. Each WebSocket connection gets its own
virtual thread. Blocking calls in handlers — database reads,
HTTP calls, waiting on locks — no longer starve the thread
pool. A single server handles tens of thousands of connections
without async code or reactive frameworks.
How do I authenticate WebSocket connections?
Section titled “How do I authenticate WebSocket connections?”Authenticate during the HTTP upgrade handshake, not after.
Implement a HandshakeInterceptor that extracts and validates
a JWT from the query string or a session cookie from the
Cookie header. Return false from beforeHandshake to
reject with a 403.
For STOMP, you can also intercept the CONNECT frame using a
ChannelInterceptor on the inbound channel. But the connection
is already open at that point. Prefer handshake-level auth to
reject unauthenticated clients before they consume server
resources.
Related Content
Section titled “Related Content”- Java WebSocket Guide - Jakarta EE, Tyrus, and Java WebSocket fundamentals
- WebSocket Security - Authentication, TLS, and rate limiting patterns
- WebSockets at Scale - Scaling patterns for high-connection-count deployments
- WebSocket Protocol Deep Dive - The RFC 6455 protocol that Spring Boot implements
- Kubernetes WebSocket Configuration - Deploying WebSocket servers on Kubernetes