00:00

WebSockets with Spring Boot

Last updated:

WebSockets with Spring Boot let your server push data to the browser the moment something happens, instead of the browser asking again and again. A WebSocket is one long-lived, two-way connection between client and server. Live order tracking, chat, notifications and dashboards all need this kind of real-time update, and polling for them wastes requests and still arrives late.

This tutorial explains how WebSocket and its messaging protocol STOMP work in Spring Boot, and how to configure a message broker. It covers handling client messages with @MessageMapping, pushing updates with SimpMessagingTemplate, replying to subscriptions with @SubscribeMapping, and sending private errors with @SendToUser. It then builds a live order-tracking page with support chat on Spring Boot 4.1, covers the settings that matter in production, shows the real problems behind the most common pitfalls, and tests everything with a STOMP client.

1. What Is a WebSocket?

A WebSocket starts life as a normal HTTP request with an Upgrade: websocket header. If the server agrees, it answers 101 Switching Protocols, and from then on the same TCP connection stays open. Both sides can send messages at any time, until one of them closes it.

A raw WebSocket only moves text or binary messages. It says nothing about where a message should go or who wants it. That's why Spring Boot apps usually add STOMP (Simple Text Oriented Messaging Protocol) on top. STOMP gives every message a destination, such as /topic/orders/ORD-1001, and lets clients SUBSCRIBE to destinations and SEND to them, much like a small message queue.

2. WebSocket vs Polling vs Server-Sent Events

HTTP pollingServer-Sent Events (SSE)WebSocket
DirectionClient asks repeatedlyServer to client onlyBoth ways
ConnectionA new request each timeOne long HTTP responseOne upgraded connection
DelayUp to one polling intervalImmediateImmediate
Spring supportAny REST controllerSseEmitter or a FluxSTOMP with @MessageMapping, or raw WebSocketHandler
Good forRare updates, simple setupsNotifications, feeds, progress barsChat, collaboration, games, live tracking with user input

If clients only ever receive, SSE is simpler and works over plain HTTP. Choose WebSockets when clients also send, or when many different channels share one connection.

3. How STOMP over WebSocket Works in Spring Boot

How a STOMP message travels through Spring BootBrowser A, a customer, sends a chat message to /app/orders/ORD-1001/chat. Because of the /app prefix it goes to the @MessageMapping method, which validates it and returns a ChatMessage that @SendTo passes to the simple broker. Separately, the warehouse system calls the REST endpoint, and SimpMessagingTemplate sends the status update to /topic/orders/ORD-1001 on the broker. The broker knows which sessions subscribed to which destination and delivers each message to Browser A and to Browser B, the support agent, who subscribed to the chat; pages for other orders get nothing. Browser A (customer) SEND /app/orders/ ORD-1001/chat Warehouse system POST /api/orders/ ORD-1001/status (REST) @MessageMapping chat(), validates, returns ChatMessage via @SendTo SimpMessagingTemplate convertAndSend( "/topic/orders/ORD-1001") Simple broker knows who subscribed to which destination Browser A subscribed to topics Browser B (agent) subscribed to chat Other order pages get nothing /app/... → controller /topic, /queue → broker
Figure 1: /app messages go through your controller, everything for /topic and /queue goes through the broker, which delivers only to matching subscribers.

Spring routes each incoming STOMP frame by its destination prefix:

  • Frames sent to /app/... go to your @MessageMapping methods. Their return value is sent on to the broker.
  • Destinations under /topic and /queue belong to the message broker, which remembers who subscribed to what and delivers each message to every matching subscriber. The built-in simple broker keeps this in memory.
  • /user/... destinations are rewritten to a destination unique to one session, so a message reaches only that client.

Any other code, such as a REST controller or a scheduled job, can push to the broker through SimpMessagingTemplate.

4. Setup

The WebSocket starter brings Spring MVC, embedded Tomcat and Spring's messaging support. Validation is added for checking incoming messages:

pom.xml
<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-websocket</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-validation</artifactId>
    </dependency>

    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-websocket-test</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>
Version note: Spring Boot 4 uses Jackson 3, so the JSON message converter for STOMP is now JacksonJsonMessageConverter. The Jackson 2 MappingJackson2MessageConverter is deprecated. Spring Boot configures the server side for you; you only meet the converter when you build a Java client, as in section 9. Boot 4 also adds spring-boot-starter-websocket-test.

5. Core Concepts and Annotations

5.1 Enabling the Broker with @EnableWebSocketMessageBroker

One configuration class sets up the endpoint and the destination prefixes:

WebSocketConfig.java (excerpt)
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {

    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        registry.addEndpoint("/ws");                      // ws://host:8080/ws
    }

    @Override
    public void configureMessageBroker(MessageBrokerRegistry registry) {
        registry.setApplicationDestinationPrefixes("/app");
        registry.enableSimpleBroker("/topic", "/queue");
    }
}
PrefixHandled byExample
/appYour @MessageMapping and @SubscribeMapping methods/app/orders/ORD-1001/chat
/topicThe broker, for one-to-many messages/topic/orders/ORD-1001
/queueThe broker, by convention for one-to-one messages/queue/errors
/userRewritten to one session's private destination/user/queue/errors

5.2 Handling Client Messages with @MessageMapping and @SendTo

@MessageMapping works like @PostMapping, but for STOMP. @DestinationVariable reads a part of the destination, @Payload is the converted message body, and @Valid runs Bean Validation on it. @SendTo sends the return value to a topic, and its placeholders are filled from the destination:

OrderTrackingMessageController.java (excerpt)
@MessageMapping("/orders/{orderId}/chat")          // client sends to /app/orders/ORD-1001/chat
@SendTo("/topic/orders/{orderId}/chat")             // every subscriber of this topic receives the result
public ChatMessage chat(@DestinationVariable String orderId, @Valid @Payload ChatRequest request) {
    return new ChatMessage(orderId, request.sender(), request.text(), Instant.now());
}

5.3 Pushing from the Server with SimpMessagingTemplate

Updates that don't start with a client message, such as the warehouse changing an order's status, are pushed with SimpMessagingTemplate. It is auto-configured and can be injected anywhere: a service, a scheduled job, or a listener for application events:

OrderTrackingService.java (excerpt)
messagingTemplate.convertAndSend("/topic/orders/" + orderId, update);

5.4 Request-Reply with @SubscribeMapping

A page that subscribes late has missed earlier pushes. @SubscribeMapping answers a subscription to an /app destination once, directly to that client, without going through the broker. It is the natural way to send the current state when a page opens:

OrderTrackingMessageController.java (excerpt)
// Client subscribes to /app/orders/ORD-1001/latest and receives one reply
@SubscribeMapping("/orders/{orderId}/latest")
public OrderStatusUpdate latest(@DestinationVariable String orderId) {
    return trackingService.latestStatus(orderId);
}

5.5 Private Replies with @SendToUser and @MessageExceptionHandler

When a client sends something invalid, only that client should hear about it. @MessageExceptionHandler catches exceptions from message-handling methods, and @SendToUser sends the result to the current session's /user/queue/errors. With broadcast = false, the reply goes only to the session that caused the error, even if the same user has other tabs open. This works without logging in, because Spring falls back to the session id:

OrderTrackingMessageController.java (excerpt)
@MessageExceptionHandler
@SendToUser(destinations = "/queue/errors", broadcast = false)
public ErrorMessage handleInvalid(MethodArgumentNotValidException ex) {
    return new ErrorMessage("Invalid chat message");
}

The validation tutorial explains the constraint annotations used on ChatRequest.

6. A Complete Example: Live Order Tracking with Support Chat

A customer opens the tracking page for order ORD-1001. The page shows the current status straight away, updates live when the warehouse system reports a change through a REST endpoint, and lets the customer chat with support about the order. An unknown order returns 404 over REST and an error message over STOMP, both from the same OrderNotFoundException.

6.1 Configuration

Besides the endpoint and broker, the configuration blocks clients from sending straight to broker destinations. Section 8.3 shows why:

package com.javabytechie.shop.config;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.annotation.Configuration;
import org.springframework.messaging.Message;
import org.springframework.messaging.MessageChannel;
import org.springframework.messaging.simp.config.ChannelRegistration;
import org.springframework.messaging.simp.config.MessageBrokerRegistry;
import org.springframework.messaging.simp.stomp.StompCommand;
import org.springframework.messaging.simp.stomp.StompHeaderAccessor;
import org.springframework.messaging.support.ChannelInterceptor;
import org.springframework.messaging.support.MessageHeaderAccessor;
import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker;
import org.springframework.web.socket.config.annotation.StompEndpointRegistry;
import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer;

@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {

    private static final Logger log = LoggerFactory.getLogger(WebSocketConfig.class);

    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        // Clients open the WebSocket connection at ws://host:8080/ws
        registry.addEndpoint("/ws");
    }

    @Override
    public void configureMessageBroker(MessageBrokerRegistry registry) {
        // Destinations starting with /app go to @MessageMapping methods
        registry.setApplicationDestinationPrefixes("/app");
        // Destinations starting with /topic or /queue go straight to the in-memory broker
        registry.enableSimpleBroker("/topic", "/queue");
    }

    @Override
    public void configureClientInboundChannel(ChannelRegistration registration) {
        // Without this, any client could SEND straight to /topic/... and skip the controller
        registration.interceptors(new ChannelInterceptor() {
            @Override
            public Message<?> preSend(Message<?> message, MessageChannel channel) {
                StompHeaderAccessor accessor = MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class);
                if (accessor != null && StompCommand.SEND.equals(accessor.getCommand())) {
                    String destination = accessor.getDestination();
                    if (destination == null || !destination.startsWith("/app/")) {
                        log.warn("Dropped client message to {}", destination);
                        return null;
                    }
                }
                return message;
            }
        });
    }
}

6.2 Messages, Service and Controllers

package com.javabytechie.shop.tracking;

import java.time.Instant;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.messaging.handler.annotation.DestinationVariable;
import org.springframework.messaging.handler.annotation.MessageExceptionHandler;
import org.springframework.messaging.handler.annotation.MessageMapping;
import org.springframework.messaging.handler.annotation.Payload;
import org.springframework.messaging.handler.annotation.SendTo;
import org.springframework.messaging.handler.annotation.support.MethodArgumentNotValidException;
import org.springframework.messaging.simp.annotation.SendToUser;
import org.springframework.messaging.simp.annotation.SubscribeMapping;
import org.springframework.stereotype.Controller;

import jakarta.validation.Valid;

@Controller
public class OrderTrackingMessageController {

    private static final Logger log = LoggerFactory.getLogger(OrderTrackingMessageController.class);

    private final OrderTrackingService trackingService;

    public OrderTrackingMessageController(OrderTrackingService trackingService) {
        this.trackingService = trackingService;
    }

    // Client sends to /app/orders/{orderId}/chat; the return value goes to every subscriber of the topic
    @MessageMapping("/orders/{orderId}/chat")
    @SendTo("/topic/orders/{orderId}/chat")
    public ChatMessage chat(@DestinationVariable String orderId, @Valid @Payload ChatRequest request) {
        log.info("Chat on order {} from {}", orderId, request.sender());
        return new ChatMessage(orderId, request.sender(), request.text(), Instant.now());
    }

    // Subscribing to /app/orders/{orderId}/latest returns the current status once, to this client only
    @SubscribeMapping("/orders/{orderId}/latest")
    public OrderStatusUpdate latest(@DestinationVariable String orderId) {
        return trackingService.latestStatus(orderId);
    }

    // Errors go only to the client that caused them, at /user/queue/errors
    @MessageExceptionHandler
    @SendToUser(destinations = "/queue/errors", broadcast = false)
    public ErrorMessage handleNotFound(OrderNotFoundException ex) {
        return new ErrorMessage(ex.getMessage());
    }

    @MessageExceptionHandler
    @SendToUser(destinations = "/queue/errors", broadcast = false)
    public ErrorMessage handleInvalid(MethodArgumentNotValidException ex) {
        return new ErrorMessage("Invalid chat message");
    }
}

6.3 The Browser Client

The page uses the @stomp/stompjs library, which speaks STOMP over the browser's native WebSocket and reconnects automatically. Save it as src/main/resources/static/index.html and Spring Boot serves it at /:

index.html
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Order ORD-1001: live tracking</title>
  <script src="https://cdn.jsdelivr.net/npm/@stomp/stompjs@7.4.0/bundles/stomp.umd.min.js"></script>
</head>
<body>
  <h1>Order ORD-1001</h1>
  <p>Status: <strong id="status">connecting...</strong></p>
  <ul id="chat"></ul>
  <form id="chat-form">
    <input id="text" placeholder="Message to support" autocomplete="off">
    <button>Send</button>
  </form>
  <script>
    const orderId = 'ORD-1001';
    const statusEl = document.getElementById('status');
    const chatEl = document.getElementById('chat');
    const show = (text) => {
      const item = document.createElement('li');
      item.textContent = text;             // textContent, not innerHTML: never trust message text
      chatEl.append(item);
    };

    const client = new StompJs.Client({
      brokerURL: `ws://${location.host}/ws`,
      reconnectDelay: 5000,                      // reconnect automatically if the connection drops
      onConnect: () => {
        // 1. Current status, sent once by the @SubscribeMapping method
        client.subscribe(`/app/orders/${orderId}/latest`, (msg) => {
          statusEl.textContent = JSON.parse(msg.body).status;
        });
        // 2. Every later status change pushed by the server
        client.subscribe(`/topic/orders/${orderId}`, (msg) => {
          const update = JSON.parse(msg.body);
          statusEl.textContent = update.status;
          show(`Status: ${update.status} - ${update.note}`);
        });
        // 3. Chat for this order, and errors meant only for this browser
        client.subscribe(`/topic/orders/${orderId}/chat`, (msg) => {
          const m = JSON.parse(msg.body);
          show(`${m.sender}: ${m.text}`);
        });
        client.subscribe('/user/queue/errors', (msg) => show(`Error: ${JSON.parse(msg.body).error}`));
      }
    });

    document.getElementById('chat-form').addEventListener('submit', (event) => {
      event.preventDefault();
      const input = document.getElementById('text');
      client.publish({
        destination: `/app/orders/${orderId}/chat`,
        body: JSON.stringify({ sender: 'Asha', text: input.value })
      });
      input.value = '';
    });

    client.activate();
  </script>
</body>
</html>

6.4 Run It

Start the app with ./mvnw spring-boot:run. Set an initial status over REST, then open http://localhost:8080:

Terminal
$ curl -s -X POST localhost:8080/api/orders/ORD-1001/status -H 'Content-Type: application/json' \
    -d '{"status":"PACKED","note":"Packed at the Pune warehouse"}'
{"orderId":"ORD-1001","status":"PACKED","note":"Packed at the Pune warehouse","at":"2026-10-25T16:06:06.423453Z"}

$ curl -s -i localhost:8080/api/orders/ORD-9999/status
HTTP/1.1 404
{"detail":"Order not found: ORD-9999","instance":"/api/orders/ORD-9999/status","status":404,"title":"Not Found"}

The page loads with "PACKED", from @SubscribeMapping. While it is open, the warehouse reports a new status:

Terminal
$ curl -s -X POST localhost:8080/api/orders/ORD-1001/status -H 'Content-Type: application/json' \
    -d '{"status":"SHIPPED","note":"Handed to SwiftShip"}'

The page updates instantly. Sending a chat message, and then an empty one, adds this to the page, read here from a headless browser:

Page content (browser)
Status: SHIPPED
- Status: SHIPPED - Handed to SwiftShip
- Asha: Can you deliver after 6 pm?
- Error: Invalid chat message
Console
INFO c.j.shop.tracking.OrderTrackingService : Order ORD-1001 is now PACKED
INFO c.j.shop.tracking.OrderTrackingService : Order ORD-1001 is now SHIPPED
INFO c.j.s.t.OrderTrackingMessageController : Chat on order ORD-1001 from Asha

7. Configuration Options

SettingWhereDefaultWhy change it
Allowed originsaddEndpoint("/ws").setAllowedOriginPatterns(...)Same origin onlyWhen the page is served from another domain
Message size limitconfigureWebSocketTransport: setMessageSizeLimit64 KBLarger STOMP messages
Send time limitsetSendTimeLimit10 secondsSlow clients: how long sending to one session may take
Send buffer limitsetSendBufferSizeLimit512 KBHow much may queue up for a slow client before it is disconnected
HeartbeatsenableSimpleBroker(...).setHeartbeatValue(...)Off for the simple brokerKeep idle connections alive through proxies and detect dead clients
SockJS fallbackaddEndpoint("/ws").withSockJS()OffOnly for networks that block WebSockets; modern browsers don't need it
External brokerenableStompBrokerRelay(...)Simple in-memory brokerRunning more than one instance (section 8.5)
WebSocketConfig.java (production settings, excerpt)
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
    registry.addEndpoint("/ws")
            .setAllowedOriginPatterns("https://*.javabytechie.com");
}

@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
    registry.setApplicationDestinationPrefixes("/app");
    registry.enableSimpleBroker("/topic", "/queue")
            .setHeartbeatValue(new long[] {10_000, 10_000})   // server sends / expects a heartbeat every 10 s
            .setTaskScheduler(heartbeatScheduler);
}

@Override
public void configureWebSocketTransport(WebSocketTransportRegistration registration) {
    registration.setMessageSizeLimit(128 * 1024)
            .setSendTimeLimit(15_000)
            .setSendBufferSizeLimit(512 * 1024);
}

Heartbeats need a TaskScheduler bean, such as a ThreadPoolTaskScheduler, passed to setTaskScheduler.

8. Common Pitfalls and How to Fix Them

8.1 Handshake Fails with 403 Forbidden

By default only pages from the same origin may connect. A raw handshake shows the difference:

Terminal
$ curl -s -i -H 'Connection: Upgrade' -H 'Upgrade: websocket' -H 'Sec-WebSocket-Version: 13' \
    -H 'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==' -H 'Origin: http://localhost:8080' localhost:8080/ws
HTTP/1.1 101

# the same request with -H 'Origin: http://admin.javabytechie.com'
HTTP/1.1 403

Clients that send no Origin header, such as server-side Java clients, are accepted. How to fix it: list the origins that may connect with setAllowedOriginPatterns("https://*.javabytechie.com"). Avoid "*", which lets any website open connections using your users' cookies.

8.2 Messages Sent Without /app Do Nothing

A client that sends to /orders/ORD-1001/chat instead of /app/orders/ORD-1001/chat gets no error and no reply. Nothing matches, and the message is dropped. How to fix it: always include the application prefix when sending, and leave it out of @MessageMapping values.

8.3 Clients Publishing Straight to a Topic

Without the guard in WebSocketConfig, a client can SEND directly to /topic/orders/ORD-1001/chat. That skips your controller and its validation. In a test, a forged message "Support: Click http://refund-now.example" reached every customer watching the order. With the guard, the message is dropped and logged:

Console
WARN c.j.shop.config.WebSocketConfig : Dropped client message to /topic/orders/ORD-1001/chat

How to fix it: reject client SEND frames to broker prefixes with a ChannelInterceptor, as shown. In apps that use Spring Security, its message authorization rules can deny SEND to /topic/** and also restrict who may subscribe to what.

8.4 Connection Closed with Code 1009

Large messages can be rejected by the servlet container before Spring sees them. In this setup, chat messages above roughly 16 KB closed the connection:

Console (logging.level.org.springframework.web.socket=DEBUG)
StandardWebSocketSession[id=9048b3c4-..., uri=ws://localhost:8080/ws] closed with CloseStatus[code=1009, reason=The decoded text message was too big for the output buffer and the endpoint does not support partial messages]

How to fix it: keep messages small, and send files over REST instead. If you really need larger frames, raise both the container's buffer and Spring's STOMP limit. With the bean below, a 60 KB message got through, and a 70 KB one was still rejected by the 64 KB STOMP limit:

WebSocketContainerConfig.java
@Configuration
public class WebSocketContainerConfig {

    @Bean
    public ServletServerContainerFactoryBean webSocketContainer() {
        ServletServerContainerFactoryBean container = new ServletServerContainerFactoryBean();
        container.setMaxTextMessageBufferSize(64 * 1024);
        return container;
    }
}

8.5 Messages Missing When You Run Several Instances

The simple broker lives in one JVM. With two instances behind a load balancer, a status pushed on instance A never reaches customers connected to instance B. How to fix it: use enableStompBrokerRelay with an external STOMP broker such as RabbitMQ (with its STOMP plugin) or ActiveMQ, so all instances share one broker. The relay also needs the reactor-netty TCP client on the classpath.

8.6 Connections Dropping After a Minute

Proxies and load balancers often close connections that look idle, and some don't forward the Upgrade header at all. How to fix it: enable STOMP heartbeats (section 7), configure the proxy to pass Upgrade and Connection headers with a long read timeout, and keep the client's automatic reconnect switched on.

9. Testing WebSocket Endpoints with a STOMP Client

The most convincing test is a real one: start the server on a random port and connect with Spring's WebSocketStompClient. Each subscription collects messages into a BlockingQueue, so a test can wait for a message, or prove that none arrives, with poll(timeout):

OrderTrackingWebSocketTest.java
package com.javabytechie.shop.tracking;

import static org.assertj.core.api.Assertions.assertThat;

import java.lang.reflect.Type;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.TimeUnit;

import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.server.LocalServerPort;
import org.springframework.messaging.converter.JacksonJsonMessageConverter;
import org.springframework.messaging.simp.stomp.StompFrameHandler;
import org.springframework.messaging.simp.stomp.StompHeaders;
import org.springframework.messaging.simp.stomp.StompSession;
import org.springframework.messaging.simp.stomp.StompSessionHandlerAdapter;
import org.springframework.web.client.RestClient;
import org.springframework.web.socket.client.standard.StandardWebSocketClient;
import org.springframework.web.socket.messaging.WebSocketStompClient;

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

    @LocalServerPort
    int port;

    WebSocketStompClient stompClient;

    @BeforeEach
    void setUp() {
        stompClient = new WebSocketStompClient(new StandardWebSocketClient());
        stompClient.setMessageConverter(new JacksonJsonMessageConverter());
    }

    @AfterEach
    void tearDown() {
        stompClient.stop();
    }

    private StompSession connect() throws Exception {
        return stompClient.connectAsync("ws://localhost:" + port + "/ws", new StompSessionHandlerAdapter() { })
                .get(5, TimeUnit.SECONDS);
    }

    // Collects every message received on one destination
    private static <T> BlockingQueue<T> subscribe(StompSession session, String destination, Class<T> type) {
        BlockingQueue<T> received = new LinkedBlockingQueue<>();
        session.subscribe(destination, new StompFrameHandler() {
            @Override
            public Type getPayloadType(StompHeaders headers) {
                return type;
            }

            @Override
            public void handleFrame(StompHeaders headers, Object payload) {
                received.add(type.cast(payload));
            }
        });
        return received;
    }

    private void changeStatus(String orderId, String json) {
        RestClient.create("http://localhost:" + port).post()
                .uri("/api/orders/{orderId}/status", orderId)
                .header("Content-Type", "application/json")
                .body(json)
                .retrieve()
                .toBodilessEntity();
    }

    @Test
    void statusChangeIsPushedOnlyToSubscribersOfThatOrder() throws Exception {
        StompSession session = connect();
        BlockingQueue<OrderStatusUpdate> order1001 = subscribe(session, "/topic/orders/ORD-1001", OrderStatusUpdate.class);
        BlockingQueue<OrderStatusUpdate> order2002 = subscribe(session, "/topic/orders/ORD-2002", OrderStatusUpdate.class);
        Thread.sleep(200); // let the SUBSCRIBE frames reach the broker
        assertThat(order1001).isEmpty();

        changeStatus("ORD-1001", "{\"status\":\"SHIPPED\",\"note\":\"Left the Pune warehouse\"}");

        OrderStatusUpdate update = order1001.poll(5, TimeUnit.SECONDS);
        assertThat(update).isNotNull();
        assertThat(update.status()).isEqualTo(OrderStatus.SHIPPED);
        assertThat(update.note()).isEqualTo("Left the Pune warehouse");
        assertThat(order2002.poll(500, TimeUnit.MILLISECONDS)).isNull();
    }

    @Test
    void chatMessageIsBroadcastToEveryoneWatchingTheOrder() throws Exception {
        StompSession customer = connect();
        StompSession agent = connect();
        BlockingQueue<ChatMessage> customerInbox = subscribe(customer, "/topic/orders/ORD-1001/chat", ChatMessage.class);
        BlockingQueue<ChatMessage> agentInbox = subscribe(agent, "/topic/orders/ORD-1001/chat", ChatMessage.class);
        Thread.sleep(200);

        customer.send("/app/orders/ORD-1001/chat", new ChatRequest("Asha", "Can you deliver after 6 pm?"));

        assertThat(agentInbox.poll(5, TimeUnit.SECONDS).text()).isEqualTo("Can you deliver after 6 pm?");
        assertThat(customerInbox.poll(5, TimeUnit.SECONDS).sender()).isEqualTo("Asha");
    }

    @Test
    void invalidChatMessageSendsErrorOnlyToTheSender() throws Exception {
        StompSession customer = connect();
        StompSession agent = connect();
        BlockingQueue<ErrorMessage> customerErrors = subscribe(customer, "/user/queue/errors", ErrorMessage.class);
        BlockingQueue<ErrorMessage> agentErrors = subscribe(agent, "/user/queue/errors", ErrorMessage.class);
        BlockingQueue<ChatMessage> agentInbox = subscribe(agent, "/topic/orders/ORD-1001/chat", ChatMessage.class);
        Thread.sleep(200);

        customer.send("/app/orders/ORD-1001/chat", new ChatRequest("Asha", ""));

        assertThat(customerErrors.poll(5, TimeUnit.SECONDS).error()).isEqualTo("Invalid chat message");
        assertThat(agentErrors.poll(500, TimeUnit.MILLISECONDS)).isNull();
        assertThat(agentInbox.poll(500, TimeUnit.MILLISECONDS)).isNull();
    }

    @Test
    void subscribingToLatestReturnsCurrentStatusOrAnError() throws Exception {
        changeStatus("ORD-3003", "{\"status\":\"PACKED\",\"note\":\"Ready for pickup\"}");
        StompSession session = connect();
        BlockingQueue<ErrorMessage> errors = subscribe(session, "/user/queue/errors", ErrorMessage.class);
        Thread.sleep(200);

        BlockingQueue<OrderStatusUpdate> latest = subscribe(session, "/app/orders/ORD-3003/latest", OrderStatusUpdate.class);
        assertThat(latest.poll(5, TimeUnit.SECONDS).status()).isEqualTo(OrderStatus.PACKED);

        subscribe(session, "/app/orders/ORD-9999/latest", OrderStatusUpdate.class);
        assertThat(errors.poll(5, TimeUnit.SECONDS).error()).isEqualTo("Order not found: ORD-9999");
    }

    @Test
    void clientCannotPublishStraightToATopic() throws Exception {
        StompSession attacker = connect();
        StompSession customer = connect();
        BlockingQueue<ChatMessage> customerInbox = subscribe(customer, "/topic/orders/ORD-1001/chat", ChatMessage.class);
        Thread.sleep(200);

        attacker.send("/topic/orders/ORD-1001/chat",
                new ChatMessage("ORD-1001", "Support", "Click http://refund-now.example", null));

        assertThat(customerInbox.poll(1, TimeUnit.SECONDS)).isNull();
    }
}
Console
[INFO] Tests run: 5, Failures: 0, Errors: 0, Skipped: 0 -- in com.javabytechie.shop.tracking.OrderTrackingWebSocketTest

The short Thread.sleep(200) after subscribing gives the SUBSCRIBE frames time to reach the broker before anything is published. Without it, a fast test can publish too early and miss the message. See the Spring Boot testing tutorial for the other test types.

10. When to Use WebSockets

Good fitPoor fit
Chat, support conversations and collaborative editingData that changes a few times a day; plain REST is enough
Live tracking, dashboards and auctions where seconds matterOne-way notifications only; Server-Sent Events are simpler
Many channels per user over one connectionLarge file transfers; use HTTP
Multiplayer games and other low-latency interactionServer-to-server integration; use a message broker or REST

11. Best Practices

  • Use STOMP with @MessageMapping instead of raw WebSocket handlers for application messaging.
  • Design destinations like URLs: /topic/orders/{orderId} for one order, not one topic for everything.
  • Send the current state with @SubscribeMapping, then push only changes.
  • Validate incoming payloads with @Valid, and keep them small.
  • Send errors privately with @MessageExceptionHandler and @SendToUser(broadcast = false).
  • Block client SEND to broker prefixes, and add Spring Security message rules for authenticated apps.
  • Restrict allowed origins to your own domains.
  • Switch to an external broker relay before you run more than one instance.
  • Enable heartbeats and client reconnects so dropped connections recover.
  • Escape message text in the browser with textContent, never innerHTML.
  • Test with a real STOMP client, and check that messages don't reach the wrong subscribers.

12. Summary

WebSockets with Spring Boot give your app a permanent, two-way connection for real-time features. Add the WebSocket starter, enable a STOMP broker with @EnableWebSocketMessageBroker, and route /app messages to @MessageMapping methods and /topic and /queue messages to the broker. Push server-side changes with SimpMessagingTemplate, send the current state with @SubscribeMapping, and return errors privately with @SendToUser. Before production, restrict origins, block clients from sending straight to topics, tune size limits and heartbeats, and move to an external broker for multiple instances. The main recommendation: model updates as small messages on clearly named destinations, and test them end to end with a real STOMP client.