Java

Build a Scalable Real-Time Task Board with Spring Boot, STOMP, and RabbitMQ

Learn to build a scalable real-time task board with Spring Boot, STOMP, and RabbitMQ, with auth, heartbeats, and fail-safe WebSocket design.

Build a Scalable Real-Time Task Board with Spring Boot, STOMP, and RabbitMQ

I have been building real-time applications for years, but nothing prepared me for the moment I saw a production WebSocket system collapse under load. The problem was not the protocol itself; it was the architecture. Too many applications treat WebSockets as a simple upgrade from HTTP and forget that bidirectional, stateful messaging needs careful planning. This article comes from that painful lesson. I want to show you how to build a real-time collaborative task board using Spring Boot and STOMP that can scale, survive network interruptions, and keep your users happy.

Let me start with a simple question: have you ever refreshed a web page because you were unsure if the data was still live? That is exactly the problem we are solving. Instead of polling a server every few seconds, we open a persistent connection and let the server push changes the moment they happen. WebSockets make this possible, but the raw WebSocket API is low-level. We need a messaging layer on top, and STOMP is the most elegant choice for Spring Boot applications.

STOMP stands for Simple Text Oriented Messaging Protocol. Think of it as HTTP for messaging – it defines frames, destinations, and a publish-subscribe model. Spring Boot’s STOMP support is built on top of WebSockets, so you get both the low-level socket and the high-level protocol in one stack. The best part is that STOMP can run over a message broker like RabbitMQ, giving us reliability and scalability without reinventing the wheel.

Now, let me walk you through the architecture of our collaborative task board. We will have multiple clients connected to multiple Spring Boot instances. Each instance will subscribe to the same RabbitMQ exchange. When a user moves a task card on Node 1, the message goes through our STOMP relay to RabbitMQ, which then distributes it to all subscribers on Node 2, Node 3, and so on. This is the secret to horizontal scaling – you cannot store WebSocket sessions locally if you have more than one server.

How do we make sure only authenticated users can connect? The WebSocket handshake is a good place to validate tokens. I use a custom HandshakeInterceptor that reads a JWT header from the initial HTTP upgrade request. If the token is invalid, the handshake fails and the connection never opens. This avoids the common mistake of allowing any user to connect and then relying on subscription-level permissions.

Let me show you the core configuration class.

@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {

    @Override
    public void configureMessageBroker(MessageBrokerRegistry config) {
        // Use RabbitMQ as the external broker for /topic and /queue
        config.enableStompBrokerRelay("/topic", "/queue")
                .setRelayHost("localhost")
                .setRelayPort(61613)
                .setClientLogin("guest")
                .setClientPasscode("guest")
                .setSystemLogin("guest")
                .setSystemPasscode("guest")
                .setHeartbeatSenderInterval(10000)
                .setHeartbeatReceiverInterval(10000);

        // Prefix for messages that go to @MessageMapping methods
        config.setApplicationDestinationPrefixes("/app");
        // Prefix for user-specific queues (STOMP /queue)
        config.setUserDestinationPrefix("/user");
    }

    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        registry.addEndpoint("/ws")
                .setAllowedOriginPatterns("*")
                .addInterceptors(new AuthHandshakeInterceptor())
                .withSockJS(); // Provides fallback transports
    }
}

Notice the heartbeat configuration. You might ask: why do we need heartbeats? WebSocket connections can be idle for a long time. Proxies, load balancers, and firewalls sometimes close idle connections. Sending periodic heartbeats (both from server and client) keeps the connection alive and helps detect network failures quickly. I set both intervals to 10 seconds. If the server does not receive a client heartbeat for 15 seconds, it considers the client disconnected and cleans up session data.

Now, handling authentication during handshake:

public class AuthHandshakeInterceptor implements HandshakeInterceptor {

    @Override
    public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response,
                                   WebSocketHandler wsHandler, Map<String, Object> attributes) {
        // Extract JWT token from query parameter or header
        String token = ((ServletServerHttpRequest) request).getServletRequest().getParameter("token");
        if (token == null || !validateToken(token)) {
            response.setStatusCode(HttpStatus.UNAUTHORIZED);
            return false;
        }
        // Extract user identity from token and store in session attributes
        String userId = extractUserId(token);
        attributes.put("userId", userId);
        return true;
    }

    // trivial validation – in real app use a proper JWT parser
    private boolean validateToken(String token) {
        return token != null && token.length() > 10;
    }

    private String extractUserId(String token) {
        return "user_" + token.hashCode();
    }

    @Override
    public void afterHandshake(ServerHttpRequest request, ServerHttpResponse response,
                               WebSocketHandler wsHandler, Exception exception) {
    }
}

After the handshake, we need to map incoming messages to controller methods. The beauty of STOMP is that a simple annotation @MessageMapping does the job. For our task board, the main use case is updating a task’s status. The user drags a card to “In Progress”, the client sends a STOMP message to /app/task.update with a payload, and the server broadcasts the change to all subscribed clients via /topic/board.

@Controller
public class TaskController {

    @Autowired
    private SimpMessagingTemplate messagingTemplate;

    @MessageMapping("/task.update")
    @SendTo("/topic/board")
    public Task updateTask(Task task, Principal principal) {
        // Save to database (omitted)
        // Add user info for presence tracking
        task.setUpdatedBy(principal.getName());
        return task;
    }
}

But what if you want to send a message only to a specific user? For instance, when another user starts typing a comment, you want to send a typing indicator only to that user. Use @SendToUser or SimpMessagingTemplate.convertAndSendToUser(). Under the hood, Spring prefixes the destination with /user/{userId}/queue/.... The client must subscribe to /user/queue/notifications.

Let me show you a typing indicator example:

@MessageMapping("/typing")
public void typingIndicator(@Payload String boardId, Principal principal) {
    // Who is typing
    String userName = principal.getName();
    // Notify all other users on this board
    messagingTemplate.convertAndSend("/topic/board/" + boardId + "/typing", userName);
}

Presence tracking is essential for a collaborative app. When a user connects or disconnects, we need to update the list of active participants. I use Redis to store the set of online users per board. On connect, we add the user to a Redis set; on disconnect, we remove them. We also publish a presence event via STOMP to inform other clients.

To capture connect/disconnect events, we implement ApplicationListener<SessionConnectEvent> and ApplicationListener<SessionDisconnectEvent>. Spring’s STOMP support fires these events during the WebSocket lifecycle.

@Component
public class PresenceEventListener implements ApplicationListener<SessionDisconnectEvent> {

    @Autowired
    private SimpMessagingTemplate messagingTemplate;
    @Autowired
    private RedisTemplate<String, String> redisTemplate;

    @Override
    public void onApplicationEvent(SessionDisconnectEvent event) {
        StompHeaderAccessor sha = StompHeaderAccessor.wrap(event.getMessage());
        String userId = sha.getSessionAttributes().get("userId").toString();
        String boardId = sha.getSessionAttributes().get("boardId").toString();

        redisTemplate.opsForSet().remove("board:" + boardId + ":users", userId);
        messagingTemplate.convertAndSend("/topic/board/" + boardId + "/presence",
                new PresenceEvent(userId, false));
    }
}

Now, consider the scenario where we have two Spring Boot instances behind a load balancer. The client connects to Node 1, but the disconnect event must be handled on whichever Node the client was connected to. With a shared Redis store and an external message broker (RabbitMQ), the presence update is automatically broadcast to all nodes. That is why the architecture with a dedicated STOMP relay is non-negotiable for production.

What about testing? You can start a test STOMP client using Spring’s StompSession and verify that messages are delivered. Here is a simple integration test:

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class TaskControllerIntegrationTest {

    @Autowired
    private TestRestTemplate restTemplate;

    @LocalServerPort
    private int port;

    private WebSocketStompClient stompClient;

    @BeforeEach
    void setup() {
        stompClient = new WebSocketStompClient(new SockJsClient(
                List.of(new WebSocketTransport(new StandardWebSocketClient()))));
    }

    @Test
    void testTaskUpdateBroadcast() throws Exception {
        // Connect to WebSocket
        StompSession session = stompClient.connect(
                "ws://localhost:" + port + "/ws?token=valid",
                new StompSessionHandlerAdapter() {})
                .get(5, SECONDS);

        // Subscribe to the board topic
        CountDownLatch latch = new CountDownLatch(1);
        session.subscribe("/topic/board", new StompFrameHandler() {
            @Override
            public Type getPayloadType(StompHeaders headers) {
                return Task.class;
            }

            @Override
            public void handleFrame(StompHeaders headers, Object payload) {
                Task received = (Task) payload;
                assertEquals("Fix login bug", received.getTitle());
                latch.countDown();
            }
        });

        // Send a message from the client
        Task task = new Task("Fix login bug", "In Progress");
        session.send("/app/task.update", task);

        assertTrue(latch.await(5, SECONDS));
    }
}

One more thing that will save you hours of debugging: error handling. When a client sends malformed data or the broker is down, the server should return a STOMP ERROR frame. You can configure a custom StompSubProtocolErrorHandler to send friendly messages.

@Bean
public StompSubProtocolErrorHandler stompErrorHandler() {
    return new StompSubProtocolErrorHandler() {
        @Override
        public Message<byte[]> handleClientMessageProcessingError(
                Message<byte[]> clientMessage, Throwable ex) {
            String message = "Sorry, we could not process your request. Please try again.";
            StompHeaderAccessor accessor = StompHeaderAccessor.create(StompCommand.ERROR);
            accessor.setMessage(message);
            return MessageBuilder.createMessage(message.getBytes(), accessor.getMessageHeaders());
        }
    };
}

I see developers often forget about reconnection strategies. The browser must handle WebSocket interruptions gracefully. Using SockJS with a fallback transport (XHR streaming, iframe, etc.) gives us resilience. On the client side, we configure STOMP.js to automatically reconnect. The server should not store sensitive state in session memory; always use Redis or a database.

So here is a practical checklist when you deploy your collaborative WebSocket application:

  1. Use a dedicated broker like RabbitMQ from day one – even in development.
  2. Validate tokens during the handshake, not after.
  3. Set proper heartbeat intervals that match your network environment.
  4. Track user presence in Redis and broadcast changes via STOMP.
  5. Write integration tests for at least one subscribe/send scenario.
  6. Configure error handlers to send meaningful messages to clients.
  7. Plan for reconnection: design your application state to be reconstructed from the server.

Now, let me end with a personal rule. I never assume a WebSocket connection will remain open for the entire user session. Networks glitch, laptops sleep, browsers lose tabs. Design your real-time features as if the connection could drop any second. Once you accept that, you build better software.

I would love to hear about your own experiences with WebSocket architecture. If you found this guide useful, please like the post, share it with your team, and leave a comment below. Tell me what challenge you faced building real-time features – I read every reply. Your feedback helps me write better articles like this one.


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

// Similar Posts

Keep Reading