Spring Boot WebSocket & STOMP: Real-Time Messaging for Production Systems
Learn to build real-time WebSocket and STOMP messaging in Spring Boot.
20+ years shipping production Java in banking & fintech. Everything here is grounded in real deployments.
- ✓Java 17+ (preferably 21 for virtual threads support)
- ✓Spring Boot 3.2+ and basic familiarity with Spring MVC
- ✓A running RabbitMQ or ActiveMQ instance for broker relay (optional but recommended for production)
- ✓Basic understanding of WebSocket handshake and STOMP frames
• Use Spring Boot 3.2+ with spring-boot-starter-websocket for STOMP over WebSocket. • Annotate controllers with @MessageMapping and @SendTo for pub/sub. • Configure WebSocketMessageBrokerConfigurer to set up broker relay and application destination prefixes. • Handle session management with SimpMessagingTemplate for server-to-client pushes. • Watch out for thread starvation and message ordering in high-throughput scenarios.
Imagine a chat room where you type a message and everyone sees it instantly, without refreshing the page. WebSocket is the open phone line between your browser and the server. STOMP is the polite protocol that says 'this message is for the #general channel.' Spring Boot wires it all together so you can broadcast stock prices, live scores, or payment confirmations without breaking a sweat.
| Chrome | Firefox | Safari | Edge |
|---|---|---|---|
| ✓ | ✓ | ✓ | ✓ |
Real-time communication is no longer a nice-to-have; it's table stakes for modern SaaS platforms. Think of a payment-processing dashboard where merchants need to see transaction statuses update live, or a real-time analytics pipeline streaming metrics to a frontend. HTTP polling is a crutch—latency is high, bandwidth is wasted, and your database gets hammered with pointless queries. WebSocket gives you a persistent, full-duplex connection, but raw WebSocket is like raw TCP: you have to reinvent routing, message framing, and error handling. That's where STOMP (Simple Text Oriented Messaging Protocol) comes in. It's a lightweight, text-based protocol that works over WebSocket, providing a pub/sub model with destinations (like /topic/orders or /queue/errors). Spring Boot's WebSocket support, introduced in 1.3 and matured through 3.x, abstracts away the boilerplate. You get annotation-driven message handling, configurable broker relay (RabbitMQ, ActiveMQ), and built-in security with Spring Security. But here's the hard truth: most teams get this wrong. They assume WebSocket is 'just another HTTP endpoint' and crash when concurrency hits. They miss thread pool tuning, forget to handle session cleanup, or misconfigure the broker relay, leading to silent message drops. In this guide, I'll walk you through building a production-grade WebSocket service with Spring Boot 3.2, covering configuration, message flow, security, and a real incident from a payment system that lost $50k in transaction updates due to a STOMP misconfiguration.
Why WebSocket and STOMP Beat Raw WebSocket for Real-Time Apps
If you’ve ever built a real-time feature like a live chat or a stock ticker using raw WebSocket, you know the pain: no message routing, no topic subscriptions, and you’re left parsing ad-hoc JSON by hand. STOMP (Simple Text Oriented Messaging Protocol) sits on top of WebSocket and gives you a pub/sub model that feels like JMS or RabbitMQ. In Spring Boot, spring-boot-starter-websocket integrates STOMP natively, so you can define destinations like /topic/prices or /queue/orders and let the framework handle routing.
Here’s the hard truth: most teams get this wrong by mixing raw WebSocket with STOMP. STOMP is a text-based protocol—don’t try to send binary frames over it. Stick to JSON payloads. The real power comes from Spring’s @MessageMapping and @SendTo annotations. You annotate a controller method, and Spring automatically deserializes the incoming STOMP frame’s body into a POJO, then routes the response to a destination. No manual session management.
Example: a simple price update endpoint. The client subscribes to /topic/prices, and the server pushes updates every second. The code below shows the server-side controller. The client (JavaScript with SockJS and STOMP.js) connects and listens. This pattern scales to thousands of concurrent connections if you configure a broker like RabbitMQ instead of Spring’s simple in-memory broker.
What the Official Docs Won't Tell You
Spring’s reference docs are thorough but miss three critical gotchas that will bite you in production. First, the default in-memory broker (SimpleBrokerMessageHandler) is single-threaded and will drop messages under load if you don’t configure a proper task executor. Always set spring.websocket.max-text-message-size to a reasonable value (e.g., 8KB) to prevent memory exhaustion from oversized frames. Second, STOMP heartbeats are not enabled by default in Spring Boot 2.x or 3.x. Without heartbeats, proxies like Nginx will close idle WebSocket connections after 60 seconds, causing silent disconnects. Configure spring.websocket.heartbeat-time to 10000 (10 seconds) to keep connections alive.
Third, the official docs gloss over security. @MessageMapping methods are not automatically protected by Spring Security. You must add a ChannelInterceptor to validate authentication tokens on the CONNECT frame. I’ve seen teams expose internal endpoints like /app/admin/delete because they assumed security was inherited from HTTP. It’s not. Use StompHeaderAccessor to extract the session’s Principal and check roles before processing.
Finally, never use @SendToUser without a session-based queue. It sends to a user’s session, but if the user has multiple tabs, each tab gets a separate session. Use /user/{username}/queue/messages with a custom UserDestinationResolver to broadcast to all sessions for that user. The docs show the simple case; the real world is multi-session.
Configuring the STOMP Broker with RabbitMQ for Scalability
Spring Boot’s in-memory broker is fine for demos, but in production with more than a few hundred users, you need an external broker like RabbitMQ. RabbitMQ speaks STOMP natively via the rabbitmq-stomp plugin. The setup is straightforward: enable the plugin on RabbitMQ, then configure Spring to relay messages to it instead of using the simple broker. This offloads message routing and persistence to RabbitMQ, which handles clustering, message durability, and back-pressure.
In your WebSocketConfig, replace enableSimpleBroker with enableStompBrokerRelay. You’ll need to specify the RabbitMQ host, port (default 61613 for STOMP), and credentials. The relay acts as a bridge: Spring Boot receives STOMP frames from clients, forwards them to RabbitMQ, and RabbitMQ broadcasts to all subscribed clients. This decouples your application from the messaging layer.
Critical detail: RabbitMQ’s STOMP adapter uses virtual hosts. If you’re using a non-default vhost (e.g., /myapp), set it in the relay properties. Also, set systemLogin and systemPasscode for the internal system user that Spring uses to manage queues. If you omit these, Spring defaults to guest/guest, which is a security hole in production. Finally, configure spring.rabbitmq.stomp.heartbeat to match your client heartbeat interval to avoid mismatches.
Here’s the config. Notice the setSystemLogin and setSystemPasscode calls—these are for Spring’s internal connection to RabbitMQ, not for client authentication. Clients authenticate separately via the CONNECT frame’s headers.
Testing WebSocket Endpoints with Spring Boot Test and MockStomp
Testing WebSocket controllers is notoriously tricky because you need a running server and a client that speaks STOMP. Spring Boot Test provides @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) which starts an embedded Tomcat. Combine it with StompSession from spring-websocket-test to simulate a real client. The trick is to use WebSocketStompClient connected to localhost on the random port.
First, add the dependency: org.springframework.boot:spring-boot-starter-websocket includes the test utilities. In your test class, inject the port from @LocalServerPort. Create a StompSession by connecting to ws://localhost:${port}/ws. Then subscribe to a topic and send a message via . Use a session.send()CompletableFuture to capture the response asynchronously—this avoids race conditions in assertions.
Common pitfall: the WebSocket connection may not be established immediately. Use from Awaitility. Also, remember that await().atMost(5, SECONDS).until(() -> session.isConnected())@MessageMapping methods run on a separate thread pool. If your test exits before the response is processed, the future may never complete. Always use @DirtiesContext to clean up the broker after each test.
Here’s a complete test for the price update endpoint. It sends a request and asserts the response contains the expected symbol and price. Note the use of StompFrameHandler to deserialize the payload. This pattern works for integration tests, but for unit tests, mock the service layer and test the controller’s logic directly without the WebSocket stack.
@DirtiesContext was missing, so the broker state leaked between tests, causing subscription failures.WebSocketStompClient and CompletableFuture to handle async responses reliably.Securing WebSocket Endpoints with JWT Authentication
Here's the hard truth: most teams get WebSocket security wrong. They assume that since the initial HTTP handshake is secured, all subsequent STOMP frames are safe. That's a production incident waiting to happen. In Spring Boot 3.2+, you must secure both the handshake and every STOMP message. Start by configuring a custom ChannelInterceptor that extracts the JWT token from the STOMP CONNECT frame's header (typically "Authorization: Bearer <token>"). Use Spring Security's AuthenticationManager to validate the token. Do NOT rely on the HttpSession or cookies — WebSockets don't guarantee they'll be sent. For example, in your WebSocketMessageBrokerConfigurer, override configureClientInboundChannel() to add a filter that sets the SecurityContextHolder. This ensures that @MessageMapping methods can use @AuthenticationPrincipal. Also, implement a ChannelInterceptor that rejects frames with missing or expired tokens before they reach your controllers. Remember: STOMP frames are stateless; each one must be authenticated independently. The official docs show a simple case, but in production with a microservice architecture, you'll also need to propagate the principal across service boundaries. Use a shared JWT secret or a public key endpoint for verification. Never store secrets in application.properties — use a vault or environment variables.
Handling Stale Connections and Heartbeat Management
Stale connections are the silent killers of WebSocket-based systems. In a SaaS billing application, we once had 30% of connections hanging because clients didn't close properly. Spring Boot's WebSocket support includes a heartbeat mechanism via STOMP's heart-beat header, but it's opt-in. You must configure a TaskScheduler and set heartbeat intervals in your WebSocketMessageBrokerConfigurer. For example, setHeartbeatTime(new long[]{10000, 10000}) sends a heartbeat every 10 seconds. The server will close connections that don't respond after two missed heartbeats. However, the default scheduler uses a single thread, which is a bottleneck. Override it with a ThreadPoolTaskScheduler of at least 4 threads for moderate load. Additionally, implement a custom WebSocketHandlerDecorator to log and clean up sessions on close. Use the SessionDisconnectEvent from Spring's ApplicationEventPublisher to update your user presence system. Remember: the client must also handle heartbeats. Many JavaScript STOMP libraries (like @stomp/stompjs v7) have a heartbeatIncoming/outgoing config. If the client doesn't send heartbeats, the server will drop it. In production, monitor the number of active sessions via Actuator's /actuator/metrics/websocket.sessions. If you see a spike in disconnects, check your heartbeat intervals first. They're often misaligned between client and server.
Scaling WebSockets Across Multiple Instances with Redis Pub/Sub
If your Spring Boot app runs on a single instance, the simple broker works fine. But in production, you'll have multiple instances behind a load balancer. Here's the hard truth: the simple broker doesn't scale. Messages published to /topic on instance A won't reach clients connected to instance B. You need a shared message broker. Redis Pub/Sub is the most common choice for Spring Boot apps. Configure it with @EnableRedisWebSocketMessageBroker and a RedisMessageListenerContainer. Spring Boot 3.2's auto-configuration for Redis is excellent, but you must manually set the channel prefix to avoid collisions. Use spring.redis.channel-prefix=myapp:ws:. The Redis broker will relay messages between instances. However, Redis Pub/Sub is fire-and-forget — if a subscriber is down, messages are lost. For guaranteed delivery, consider Redis Streams or RabbitMQ with STOMP plugin. In our payment-processing system, we used Redis Pub/Sub with a fallback to a database queue for critical notifications. Also, beware of serialization: STOMP messages are typically strings, but if you send Java objects, ensure they're serializable and use a consistent JSON format. Test with multiple instances locally using Docker Compose. Finally, configure the load balancer to use sticky sessions (session affinity) based on the JSESSIONID cookie. Without it, a client might reconnect to a different instance and miss messages until the Redis subscription is established.
Testing WebSocket Endpoints with Spring Boot Test and WebSocketStompClient
Most developers only test REST endpoints, leaving WebSocket logic to manual testing. That's a recipe for silent failures. Spring Boot provides WebSocketStompClient for integration testing. Use it with a TestRestTemplate to authenticate first, then connect to the STOMP endpoint. For example, in a JUnit 5 test, start the application with @SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT). Create a WebSocketStompClient instance using the standard WebSocket container (Tomcat or Jetty). Connect to ws://localhost:${port}/ws with STOMP headers including the JWT. Subscribe to a topic and use a BlockingQueue to collect messages. Then, simulate a message by sending to /app/someEndpoint via the client. Assert that the expected message arrives within a timeout. This tests the entire pipeline: security, broker, and controller. However, there's a gotcha: the default WebSocket client doesn't support SockJS fallback. If your app uses SockJS, use the SockJsClient wrapper. Also, beware of timing — STOMP is asynchronous. Use CountDownLatch or CompletableFuture with a 5-second timeout. In our CI pipeline, we run these tests in parallel with REST tests using @Nested classes. For performance testing, use a load generator like Gatling with WebSocket support. Remember: test both happy paths and error cases — expired tokens, invalid destinations, and broker failures. The official docs show a basic example, but they don't cover testing with security or multiple instances.
The $50k Transaction Update Blackout: A STOMP Broker Relay Horror Story
@SendTo messages after 10 minutes of uptime. No errors in logs. WebSocket connections remained open, but STOMP frames silently disappeared.ScheduledExecutorService for heartbeats and message delivery. Under load, the thread pool becomes saturated, dropping messages without any warning. Additionally, the heartbeat interval was too aggressive (10,000ms), causing the broker to disconnect idle clients.spring.websocket.broker.relay.heartbeat-send-interval to 30000ms and heartbeat-receive-interval to 30000ms. Increased the task executor pool size to 20 threads via WebSocketMessageBrokerConfigurer.configureClientInboundChannel().- Never use the Simple Broker in production for more than 50 concurrent users. It's a toy, not a tool.
- Always configure explicit thread pools for inbound and outbound channels. The defaults are dangerously small.
- Monitor WebSocket sessions with a custom
ApplicationListener<SessionDisconnectEvent>to detect silent disconnections.
curl -H "Authorization: Bearer <token>" -H "Upgrade: websocket" http://localhost:8080/wsCheck Spring Security logs: grep 'Authentication' /var/log/app.log| File | Command / Code | Purpose |
|---|---|---|
| PriceController.java | @Controller | Why WebSocket and STOMP Beat Raw WebSocket for Real-Time App |
| WebSocketSecurityConfig.java | @Configuration | What the Official Docs Won't Tell You |
| WebSocketConfig.java | @Configuration | Configuring the STOMP Broker with RabbitMQ for Scalability |
| PriceControllerTest.java | @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) | Testing WebSocket Endpoints with Spring Boot Test and MockSt |
| WebSocketHeartbeatConfig.java | @Configuration | Handling Stale Connections and Heartbeat Management |
| RedisWebSocketConfig.java | @Configuration | Scaling WebSockets Across Multiple Instances with Redis Pub/ |
| WebSocketIntegrationTest.java | @SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT) | Testing WebSocket Endpoints with Spring Boot Test and WebSoc |
Key takeaways
Interview Questions on This Topic
Explain how STOMP frames are structured and how Spring Boot maps them to @MessageMapping methods.
Frequently Asked Questions
20+ years shipping production Java in banking & fintech. Everything here is grounded in real deployments.
That's Spring Boot. Mark it forged?
7 min read · try the examples if you haven't