WebSockets with Spring Boot
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 polling | Server-Sent Events (SSE) | WebSocket | |
|---|---|---|---|
| Direction | Client asks repeatedly | Server to client only | Both ways |
| Connection | A new request each time | One long HTTP response | One upgraded connection |
| Delay | Up to one polling interval | Immediate | Immediate |
| Spring support | Any REST controller | SseEmitter or a Flux | STOMP with @MessageMapping, or raw WebSocketHandler |
| Good for | Rare updates, simple setups | Notifications, feeds, progress bars | Chat, 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
Spring routes each incoming STOMP frame by its destination prefix:
- Frames sent to
/app/...go to your@MessageMappingmethods. Their return value is sent on to the broker. - Destinations under
/topicand/queuebelong 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:
<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>
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:
@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");
}
}
| Prefix | Handled by | Example |
|---|---|---|
/app | Your @MessageMapping and @SubscribeMapping methods | /app/orders/ORD-1001/chat |
/topic | The broker, for one-to-many messages | /topic/orders/ORD-1001 |
/queue | The broker, by convention for one-to-one messages | /queue/errors |
/user | Rewritten 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:
@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:
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:
// 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:
@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;
}
});
}
}
package com.javabytechie.shop.config;
import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import com.javabytechie.shop.tracking.OrderNotFoundException;
@RestControllerAdvice
public class RestExceptionHandler {
@ExceptionHandler(OrderNotFoundException.class)
public ProblemDetail notFound(OrderNotFoundException ex) {
return ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
}
}
package com.javabytechie.shop;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class ShopApplication {
public static void main(String[] args) {
SpringApplication.run(ShopApplication.class, args);
}
}
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");
}
}
package com.javabytechie.shop.tracking;
import java.time.Instant;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.stereotype.Service;
@Service
public class OrderTrackingService {
private static final Logger log = LoggerFactory.getLogger(OrderTrackingService.class);
private final SimpMessagingTemplate messagingTemplate;
// Latest status per order; a real shop would read this from the orders table
private final Map<String, OrderStatusUpdate> latest = new ConcurrentHashMap<>();
public OrderTrackingService(SimpMessagingTemplate messagingTemplate) {
this.messagingTemplate = messagingTemplate;
}
public OrderStatusUpdate changeStatus(String orderId, StatusChangeRequest request) {
OrderStatusUpdate update = new OrderStatusUpdate(orderId, request.status(), request.note(), Instant.now());
latest.put(orderId, update);
// Push to every client subscribed to this order's topic
messagingTemplate.convertAndSend("/topic/orders/" + orderId, update);
log.info("Order {} is now {}", orderId, request.status());
return update;
}
public OrderStatusUpdate latestStatus(String orderId) {
OrderStatusUpdate update = latest.get(orderId);
if (update == null) {
throw new OrderNotFoundException(orderId);
}
return update;
}
}
package com.javabytechie.shop.tracking;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import jakarta.validation.Valid;
// Called by the warehouse system over plain HTTP; the change is pushed to browsers over WebSocket
@RestController
@RequestMapping("/api/orders/{orderId}/status")
public class OrderStatusController {
private final OrderTrackingService trackingService;
public OrderStatusController(OrderTrackingService trackingService) {
this.trackingService = trackingService;
}
@PostMapping
public OrderStatusUpdate change(@PathVariable String orderId, @Valid @RequestBody StatusChangeRequest request) {
return trackingService.changeStatus(orderId, request);
}
@GetMapping
public OrderStatusUpdate latest(@PathVariable String orderId) {
return trackingService.latestStatus(orderId);
}
}
package com.javabytechie.shop.tracking;
import java.time.Instant;
public record OrderStatusUpdate(String orderId, OrderStatus status, String note, Instant at) {
}
package com.javabytechie.shop.tracking;
public enum OrderStatus {
PLACED, PACKED, SHIPPED, OUT_FOR_DELIVERY, DELIVERED
}
package com.javabytechie.shop.tracking;
import jakarta.validation.constraints.NotNull;
public record StatusChangeRequest(@NotNull OrderStatus status, String note) {
}
package com.javabytechie.shop.tracking;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
public record ChatRequest(@NotBlank String sender, @NotBlank @Size(max = 500) String text) {
}
package com.javabytechie.shop.tracking;
import java.time.Instant;
public record ChatMessage(String orderId, String sender, String text, Instant at) {
}
package com.javabytechie.shop.tracking;
public record ErrorMessage(String error) {
}
package com.javabytechie.shop.tracking;
public class OrderNotFoundException extends RuntimeException {
public OrderNotFoundException(String orderId) {
super("Order not found: " + orderId);
}
}
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 /:
<!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:
$ 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:
$ 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:
Status: SHIPPED
- Status: SHIPPED - Handed to SwiftShip
- Asha: Can you deliver after 6 pm?
- Error: Invalid chat message
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
| Setting | Where | Default | Why change it |
|---|---|---|---|
| Allowed origins | addEndpoint("/ws").setAllowedOriginPatterns(...) | Same origin only | When the page is served from another domain |
| Message size limit | configureWebSocketTransport: setMessageSizeLimit | 64 KB | Larger STOMP messages |
| Send time limit | setSendTimeLimit | 10 seconds | Slow clients: how long sending to one session may take |
| Send buffer limit | setSendBufferSizeLimit | 512 KB | How much may queue up for a slow client before it is disconnected |
| Heartbeats | enableSimpleBroker(...).setHeartbeatValue(...) | Off for the simple broker | Keep idle connections alive through proxies and detect dead clients |
| SockJS fallback | addEndpoint("/ws").withSockJS() | Off | Only for networks that block WebSockets; modern browsers don't need it |
| External broker | enableStompBrokerRelay(...) | Simple in-memory broker | Running more than one instance (section 8.5) |
@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:
$ 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:
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:
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:
@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):
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();
}
}
[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 fit | Poor fit |
|---|---|
| Chat, support conversations and collaborative editing | Data that changes a few times a day; plain REST is enough |
| Live tracking, dashboards and auctions where seconds matter | One-way notifications only; Server-Sent Events are simpler |
| Many channels per user over one connection | Large file transfers; use HTTP |
| Multiplayer games and other low-latency interaction | Server-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
@MessageExceptionHandlerand@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, neverinnerHTML. - 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.
Frequently Asked Questions (FAQ)
Q1. What is the difference between WebSocket and STOMP?
WebSocket is the transport: one long-lived, two-way connection that carries raw text or binary messages. STOMP is a small messaging protocol that runs on top of it and adds destinations, subscriptions and headers. Spring's @MessageMapping and its message broker work with STOMP.
Q2. Do I still need SockJS?
Usually not. Every modern browser supports WebSocket, so SockJS is only useful when a corporate proxy or old network equipment blocks WebSocket connections. If you add it with withSockJS(), clients must use the SockJS client library and an http:// URL instead of ws://.
Q3. @SendTo vs SimpMessagingTemplate: which should I use?
Use @SendTo when a message comes from a client and the reply goes to a topic, as in chat. Use SimpMessagingTemplate.convertAndSend when the event starts on the server, such as a REST call, a database change or a scheduled job.
Q4. How do I send a message to one specific user?
Subscribe the client to /user/queue/... and send with convertAndSendToUser(username, "/queue/...", payload) or @SendToUser. With Spring Security the username comes from the logged-in user; without authentication, @SendToUser replies to the current session.
Q5. How do I secure Spring Boot WebSockets?
Authenticate the HTTP handshake like any other request, for example with a session cookie or token. Then restrict STOMP messages: Spring Security can authorize SEND and SUBSCRIBE by destination. Also limit allowed origins and block clients from sending directly to broker destinations.
Q6. Why does my WebSocket connection fail with 403?
The page is served from an origin that the endpoint doesn't allow. Spring accepts only same-origin browser connections by default. Add your front-end's origin with setAllowedOriginPatterns on the endpoint registration.
Q7. Can Spring Boot WebSockets scale to several instances?
Not with the simple in-memory broker, because each instance only knows its own subscribers. Use enableStompBrokerRelay with RabbitMQ or ActiveMQ so all instances publish and subscribe through one shared broker. Sticky sessions are not required for STOMP over native WebSocket, since each connection stays on one instance.
Q8. How do I test a Spring Boot WebSocket endpoint?
Start the app with @SpringBootTest(webEnvironment = RANDOM_PORT) and connect with WebSocketStompClient, using JacksonJsonMessageConverter for JSON. Collect received messages in a BlockingQueue and use poll with a timeout to wait for them, or to prove none arrive.