I still remember the first time I tried to build a real-time collaborative whiteboard. It failed miserably because I didn’t understand the difference between a simple WebSocket and a distributed messaging system. If you’ve ever run into similar issues—messages not reaching all users, connections dropping after scaling to multiple servers, or users seeing stale data—this article is for you. I’ll guide you through building a production-grade, real-time collaborative task board using Spring Boot, STOMP over WebSocket, and Redis Pub/Sub. By the end, you’ll have a system that scales horizontally without losing a single message.
Have you ever wondered how a tool like Trello or a live trading dashboard updates everyone’s screen almost instantly? The secret is not just WebSocket, but a layered protocol called STOMP on top of it. STOMP adds structure: you get destinations, subscriptions, acknowledgements, and a clear message framing. Without STOMP, you’d have to parse raw WebSocket frames yourself and handle routing logic. That’s like building a telephone network without a switchboard—it works for two people, but fails for a thousand.
I’ll start with the core setup. Create a Spring Boot project with the following dependencies: spring-boot-starter-websocket, spring-boot-starter-security, spring-boot-starter-data-redis, spring-boot-starter-messaging, spring-boot-starter-web, and spring-boot-starter-data-jpa with PostgreSQL. For JWT, I’ll use the jjwt library. The build tool is Maven, and we’ll use Java 21.
Let me show you the configuration for WebSocket. You’ll create a class that extends WebSocketMessageBrokerConfigurer. Here, you enable STOMP over WebSocket and set up the broker relay to Redis.
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
@Override
public void configureMessageBroker(MessageBrokerRegistry config) {
// Use Redis Pub/Sub as the message broker for broadcasting to all nodes
config.enableStompBrokerRelay("/topic", "/queue")
.setRelayHost("localhost")
.setRelayPort(6379)
.setSystemLogin("admin")
.setSystemPasscode("password")
.setClientLogin("guest")
.setClientPasscode("guest");
// Application destination prefix for messages from clients
config.setApplicationDestinationPrefixes("/app");
// User destination prefix for sending to specific user
config.setUserDestinationPrefix("/user");
}
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
// WebSocket endpoint that clients will connect to
registry.addEndpoint("/ws")
.setAllowedOrigins("*")
.withSockJS(); // Fallback for browsers that don't support WebSocket
}
}
Notice I used enableStompBrokerRelay with Redis. This tells Spring to forward all messages destined for /topic and /queue to a Redis Pub/Sub channel instead of an in‑memory broker. The Redis server acts as a central hub: any Spring Boot node publishing a message to /topic/board.123 will cause Redis to broadcast it to all subscribers across every node.
Now, how do you secure the WebSocket connection? You will add an interceptor that verifies a JWT token during the handshake. Create a ChannelInterceptor for the CONNECT frame.
@Component
public class JwtChannelInterceptor implements ChannelInterceptor {
private final JwtTokenProvider tokenProvider;
public JwtChannelInterceptor(JwtTokenProvider tokenProvider) {
this.tokenProvider = tokenProvider;
}
@Override
public Message<?> preSend(Message<?> message, MessageChannel channel) {
StompHeaderAccessor accessor = StompHeaderAccessor.wrap(message);
if (StompCommand.CONNECT.equals(accessor.getCommand())) {
String token = accessor.getFirstNativeHeader("Authorization");
if (token != null && token.startsWith("Bearer ")) {
token = token.substring(7);
if (tokenProvider.validateToken(token)) {
String userId = tokenProvider.getUserId(token);
accessor.setUser(() -> userId); // Sets the Principal for this session
return message;
}
}
throw new AuthenticationCredentialsNotFoundException("Invalid JWT");
}
return message;
}
}
You must register this interceptor in the WebSocket configuration by overriding configureClientInboundChannel. This way, every connect request is validated before the WebSocket session is established. After that, you can easily get the user principal in your controller methods.
Let’s talk about the actual collaborative features. Suppose users are editing a task board. When a user moves a card, the client sends a STOMP message to /app/board.moveCard. The server processes it and publishes the update to /topic/board.{boardId}. All clients subscribed to that topic receive the update. Here’s a sample controller:
@Controller
public class BoardController {
private final SimpMessagingTemplate messagingTemplate;
public BoardController(SimpMessagingTemplate messagingTemplate) {
this.messagingTemplate = messagingTemplate;
}
@MessageMapping("/board.moveCard")
@SendTo("/topic/board.{boardId}")
public CardMoveResult moveCard(@Payload MoveCardRequest request,
@Header("simpSessionId") String sessionId,
Principal principal) {
// Validate request, update database
String boardId = request.getBoardId();
CardMoveResult result = boardService.moveCard(request);
// The @SendTo annotation already returns the result to /topic/board.{boardId}
return result;
}
}
But wait—this uses @SendTo, which is fine for simple cases. However, when you have multiple nodes, @SendTo publishes only on the local broker. If Redis is not configured correctly, the message stays on Node 1. With the broker relay, any message coming from the application to a /topic or /queue destination is automatically forwarded to Redis, and from there to all nodes. That’s why we used enableStompBrokerRelay.
I should also mention a common pitfall: user‑specific messaging. If you send a message to a specific user (e.g., private notifications), use convertAndSendToUser. Spring’s SimpMessagingTemplate will route it through the /user prefix, and the broker relay will ensure it reaches the correct node where that user’s session exists.
messagingTemplate.convertAndSendToUser(
user.getUsername(),
"/queue/notifications",
notificationPayload
);
This works because we set setUserDestinationPrefix("/user") in the config. The server appends the session id to the destination behind the scenes.
Now, what happens when a user disconnects unexpectedly? You need to clean up and notify others. Implement a SessionDisconnectEvent listener.
@Component
public class WebSocketEventListener {
@EventListener
public void handleWebSocketDisconnectListener(SessionDisconnectEvent event) {
StompHeaderAccessor headerAccessor = StompHeaderAccessor.wrap(event.getMessage());
String username = (String) headerAccessor.getSessionAttributes().get("username");
if (username != null) {
// Broadcast that user left
messagingTemplate.convertAndSend("/topic/board.status",
new UserStatus(username, "offline"));
}
}
}
You’ll need to store the username in session attributes during the connect event, which you can do in the same JwtChannelInterceptor after authentication.
I won’t lie—there are plenty of hidden traps. One is the heartbeat configuration. By default, STOMP heartbeats are sent every 10 seconds. If your network is behind a proxy, you might need to increase this interval. Also, large messages (e.g., serialized board data) can exceed the WebSocket frame size limit. In Spring Boot, you can increase the max buffer size via TomcatWebSocketContainerCustomizer or by setting server.websocket.max-text-message-size=65536 in application.properties.
Have you ever tested WebSocket endpoints in your CI pipeline? It’s crucial. Use a StompClient in your integration tests. Here’s a simple test that connects, subscribes, and verifies a message.
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class WebSocketIntegrationTest {
@LocalServerPort
private int port;
private WebSocketStompClient stompClient;
@BeforeEach
void setup() {
stompClient = new WebSocketStompClient(new SockJsClient(
List.of(new WebSocketTransport(new StandardWebSocketClient()))
));
stompClient.setMessageConverter(new MappingJackson2MessageConverter());
}
@Test
void testMoveCardMessageBroadcast() throws Exception {
// Connect to /ws endpoint with a valid JWT
String token = obtainJwtToken("testuser");
List<CardMoveResult> received = new ArrayList<>();
CountDownLatch latch = new CountDownLatch(1);
StompSession session = stompClient
.connect("ws://localhost:" + port + "/ws",
new StompSessionHandlerAdapter() {},
"Authorization", "Bearer " + token)
.get(5, TimeUnit.SECONDS);
session.subscribe("/topic/board.123", new StompFrameHandler() {
@Override
public Type getPayloadType(StompHeaders headers) {
return CardMoveResult.class;
}
@Override
public void handleFrame(StompHeaders headers, Object payload) {
received.add((CardMoveResult) payload);
latch.countDown();
}
});
// Send a move card message
session.send("/app/board.moveCard",
new MoveCardRequest("123", "card1", "col2"));
assertTrue(latch.await(5, TimeUnit.SECONDS));
assertEquals(1, received.size());
assertEquals("col2", received.get(0).getTargetColumn());
}
}
This test ensures your WebSocket messaging works end‑to‑end. You need a test Redis instance, but you can use Testcontainers for that.
Let’s step back for a second. Why did I choose Redis over RabbitMQ or Kafka for the broker relay? Redis is lightweight and has a built‑in Pub/Sub model that fits perfectly for broadcast use cases. It’s not a durable message queue; if a subscriber goes down, it misses messages. But for real‑time collaboration, this is often acceptable—the last state is always persisted in the database. If you need guaranteed delivery, you’d combine Redis with a database event log.
Another question you might have: how do you handle sticky sessions? With SockJS and WebSocket, you often need session affinity at the load balancer. However, using Redis Pub/Sub eliminates that requirement because messages are forwarded to all nodes anyway. You can safely run a round‑robin load balancer.
Now, let me share a personal lesson I learned the hard way. When I first deployed this architecture, I forgot to configure the Redis password in the relay properties. The system worked locally, but in production, the Redis server rejected the connection. The WebSocket connections succeeded, but no messages were broadcasted. Users saw their own changes but not others’. The logs showed a silent failure. So always check that your Redis relay authentication matches.
Performance wise, you should benchmark with tools like wrk or jmeter using WebSocket plugins. Tweak the spring.websocket.max-text-message-size and the spring.servlet.multipart.max-file-size (if you send files). Also, monitor the Redis channel backpressure. For high‑frequency updates (e.g., cursor positions), consider throttling on the client side.
In conclusion, building a collaborative WebSocket application with Spring Boot and Redis is feasible and robust if you follow the patterns I’ve shown. You now have the foundation to create real‑time boards, chat systems, or even multiplayer game state synchronization.
If you found this article helpful, I’d really appreciate it if you could like, share it with a teammate who struggles with WebSocket scaling, and leave a comment with your own experience or questions. It helps me know what topics to cover next.
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