I remember the first time I needed to push live updates to thousands of browser windows at once. The REST endpoints were drowning in polling requests. The database queries for “what changed in the last second” were hammering the server. My manager walked over and said, “Make it real-time. And make it scale.” That’s when I learned that HTTP polling, even with long-polling tricks, is a Band-Aid. For true bidirectional communication—where the server can push data the moment it exists—you need WebSockets.
But raw WebSockets are too low-level. You get a stream of bytes. You have to design your own protocol for routing messages, handling subscriptions, and managing sessions. That’s where STOMP (Simple Text Oriented Messaging Protocol) walks in. STOMP gives structure to WebSockets: destinations like /topic/notifications, commands like SUBSCRIBE and SEND, and built-in frame types for errors and acknowledgments. Spring Boot wraps all of this into a neat, annotation-driven configuration.
Let’s be honest: if you’ve never dealt with WebSocket scaling, you might think a single Spring Boot instance is enough. It is—until your user base triples and you need to run two server nodes. Without a shared message broker, a message sent on node A never reaches a client connected to node B. That’s the moment you realize you need a broker relay, like RabbitMQ, to fan out messages across all nodes.
So why am I writing this now? Because I went through the pain of building a production-grade WebSocket system from scratch, and I want you to skip the 2am debugging sessions. This article is the guide I wish I had.
The foundation is a Spring Boot WebSocket configuration. You define a WebSocketMessageBrokerConfigurer that sets up the STOMP endpoints and the broker. Let’s start with a simple in-memory broker for local development:
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
// Enable a simple in-memory broker for topic and queue destinations
registry.enableSimpleBroker("/topic", "/queue");
// Prefix for messages from clients to server (application destinations)
registry.setApplicationDestinationPrefixes("/app");
}
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
// SockJS fallback for browsers that don't support WebSockets
registry.addEndpoint("/ws")
.setAllowedOrigins("*")
.withSockJS();
}
}
This is the classic “Hello WebSocket” setup. But in production, you need security, heartbeats, and scaling.
Have you ever deployed a WebSocket app and wondered why connections drop after 60 seconds? Firewalls and proxies often close idle WebSocket connections. The solution is STOMP heartbeats. Configure them in the WebSocketTransportRegistration:
@Override
public void configureWebSocketTransport(WebSocketTransportRegistration registration) {
registration.setSendTimeLimit(15_000)
.setSendBufferSizeLimit(512 * 1024)
.setMessageSizeLimit(128 * 1024);
}
But heartbeats alone won’t save you when your app needs to grow. Let’s talk about scaling.
For a multi-node setup, an in-memory broker is useless. You need an external message broker like RabbitMQ. Spring Boot supports this with enableStompBrokerRelay. First, install the RabbitMQ STOMP plugin:
rabbitmq-plugins enable rabbitmq_stomp
Then update your config:
@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
// Enable a STOMP broker relay to RabbitMQ
registry.enableStompBrokerRelay("/topic", "/queue")
.setRelayHost("localhost")
.setRelayPort(61613) // RabbitMQ STOMP port
.setClientLogin("guest")
.setClientPasscode("guest")
.setSystemLogin("guest")
.setSystemPasscode("guest");
registry.setApplicationDestinationPrefixes("/app");
}
Now messages sent to /topic/... are forwarded to RabbitMQ, which fans them out to all connected nodes. This is what true scalability looks like.
But wait—how do you authenticate users before they can subscribe to a topic? You can’t trust every WebSocket connection. You need to integrate Spring Security. The trick is to intercept the WebSocket handshake and pass the JWT token or session ID. Here’s a simple approach using a channel interceptor:
@Component
public class AuthChannelInterceptor implements ChannelInterceptor {
@Override
public Message<?> preSend(Message<?> message, MessageChannel channel) {
StompHeaderAccessor accessor = StompHeaderAccessor.wrap(message);
if (StompCommand.CONNECT.equals(accessor.getCommand())) {
String authHeader = accessor.getFirstNativeHeader("Authorization");
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
throw new IllegalArgumentException("Missing or invalid token");
}
String token = authHeader.substring(7);
// Validate token – throw exception if invalid
// You can set the user principal on the session
accessor.setUser(() -> token); // simple but not production-ready
}
return message;
}
}
Register this interceptor in your config:
@Override
public void configureClientInboundChannel(ChannelRegistration registration) {
registration.interceptors(authChannelInterceptor);
}
Now every STOMP CONNECT frame is validated. Reject invalid tokens, and the WebSocket closes with an error frame. This is security at the message level.
Tracking who is online is a common requirement. You can listen for session connect and disconnect events:
@Component
public class PresenceTracker {
private final Set<String> onlineUsers = ConcurrentHashMap.newKeySet();
@EventListener
public void handleSessionConnected(SessionConnectEvent event) {
StompHeaderAccessor accessor = StompHeaderAccessor.wrap(event.getMessage());
String username = accessor.getUser() != null ? accessor.getUser().getName() : "anonymous";
onlineUsers.add(username);
// Notify all clients that user came online
// using SimpMessagingTemplate
}
@EventListener
public void handleSessionDisconnected(SessionDisconnectEvent event) {
StompHeaderAccessor accessor = StompHeaderAccessor.wrap(event.getMessage());
String username = accessor.getUser() != null ? accessor.getUser().getName() : "anonymous";
onlineUsers.remove(username);
// Notify all clients that user left
}
}
When a user disconnects, remove them from the set. This is simple, but be careful: in a multi-node setup, the onlineUsers set is local to each node. To have a global presence list, you’d store it in Redis or another shared store. That’s a topic for another day, but it’s the natural next step.
Now let’s write a controller that handles a chat message and broadcasts to all subscribers:
@Controller
public class ChatController {
private final SimpMessagingTemplate messagingTemplate;
public ChatController(SimpMessagingTemplate messagingTemplate) {
this.messagingTemplate = messagingTemplate;
}
@MessageMapping("/chat.send")
@SendTo("/topic/public")
public ChatMessage sendMessage(@Payload ChatMessage message, Principal principal) {
message.setSender(principal.getName());
return message;
}
}
For user-specific messaging (private messages), use /queue destinations with the user’s session. Spring automatically subscribes each user to their own /queue/ prefix.
What happens when your client disconnects and reconnects? SockJS ensures a fallback transports: XHR streaming, iframe, or even JSONP polling. But you must handle session recovery on the server. One trick is to assign a unique subscription ID on the client and re-subscribe on reconnect. The server can store the last messages for each user and replay them.
I once spent a full day debugging why messages were being duplicated after a reconnect. The culprit was a missing autoDelete flag on the queue. In RabbitMQ, if you don’t set queues to auto-delete when the last consumer disconnects, stale queues accumulate and old subscriptions resurface. Always configure your STOMP broker relay with setVirtualHost("/") and use temporary queues for user-specific destinations.
Testing WebSocket endpoints is often neglected. Don’t be that developer. Here’s a simple integration test using a STOMP client:
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class WebSocketIntegrationTest {
@LocalServerPort
private int port;
private WebSocketStompClient stompClient;
@BeforeEach
public void setup() {
stompClient = new WebSocketStompClient(new StandardWebSocketClient());
stompClient.setMessageConverter(new MappingJackson2MessageConverter());
}
@Test
public void givenStompClient_whenMessageSent_thenMessageReceived() throws Exception {
CompletableFuture<ChatMessage> future = new CompletableFuture<>();
StompSession session = stompClient
.connect("ws://localhost:" + port + "/ws", new StompSessionHandlerAdapter() {})
.get(5, TimeUnit.SECONDS);
session.subscribe("/topic/public", new StompFrameHandler() {
@Override
public Type getPayloadType(StompHeaders headers) {
return ChatMessage.class;
}
@Override
public void handleFrame(StompHeaders headers, Object payload) {
future.complete((ChatMessage) payload);
}
});
session.send("/app/chat.send", new ChatMessage("Hello", "testuser"));
ChatMessage received = future.get(5, TimeUnit.SECONDS);
assertEquals("Hello", received.getContent());
}
}
You might ask: “Do I really need all this complexity?” For a demo app, no. For a product with thousands of concurrent users, yes. The moment you go beyond one server node, the broker relay becomes essential. The moment you need to protect sensitive data, the channel interceptor becomes essential.
Here are the common pitfalls I’ve seen (and caused):
- Missing SockJS dependency – Without it, old browsers fail silently.
- Wrong RabbitMQ STOMP port – Port 61613, not 5672.
- No heartbeat – Connections drop after 60 seconds.
- Forgetting to set
allowedOrigins– CORS blocks the handshake. - Using
@SendTowith absolute destination but broker relay configured – Ensure prefix matches. - Not handling session disconnect events – Users remain online in your presence list.
The journey from a single-node in-memory broker to a multi-node RabbitMQ relay is non-trivial. But once you understand the flow—handshake, STOMP frames, broker relay, channel interceptors—you gain the ability to build systems that feel magical. Users see messages appear instantly, no page refresh needed. That feeling never gets old.
If you’ve made it this far, you now have a blueprint for a production-grade WebSocket architecture. Start with the simple config, add security, scale with RabbitMQ, and test until you’re confident. Share your first real-time feature with your team. Watch their faces light up. Then, when something breaks, you’ll have the tools to debug it.
I’d love to hear about your own WebSocket adventures. Did you hit a problem I didn’t cover? What scaling tricks have you used? Hit the like button if this article saved you a headache, share it with a colleague who’s about to build a chat app, and leave a comment with your biggest WebSocket lesson. The conversation doesn’t end here—it just moves to a different topic.
As a best-selling author, I invite you to explore my books on Amazon. Don’t forget to follow me on Medium and show your support. Thank you! Your support means the world!
101 Books
101 Books is an AI-driven publishing company co-founded by author Aarav Joshi. By leveraging advanced AI technology, we keep our publishing costs incredibly low—some books are priced as low as $4—making quality knowledge accessible to everyone.
Check out our book Golang Clean Code available on Amazon.
Stay tuned for updates and exciting news. When shopping for books, search for Aarav Joshi to find more of our titles. Use the provided link to enjoy special discounts!
📘 Checkout my latest ebook for free on my channel!
Be sure to like, share, comment, and subscribe to the channel!
Our Creations
Be sure to check out our creations:
Investor Central | Investor Central Spanish | Investor Central German | Smart Living | Epochs & Echoes | Puzzling Mysteries | Hindutva | Elite Dev | JS Schools
We are on Medium
Tech Koala Insights | Epochs & Echoes World | Investor Central Medium | Puzzling Mysteries Medium | Science & Epochs Medium | Modern Hindutva