Use WebSockets with Spring, SockJS and Stomp

This guide walks you through building a “hello world” application that sends messages back and forth between a browser and the server. WebSocket is a very thin, lightweight layer on top of TCP, which makes it a natural place to run a subprotocol that gives the raw bytes some structure. Here we use STOMP messaging with Spring to create an interactive web application.

Note: This post has been updated for Spring 6/7. The WebSocket setup now uses @EnableWebSocketMessageBroker Java configuration and the modern @stomp/stompjs client. The original 2015 version targeted Spring 4.1 with XML <websocket:message-broker> config and the legacy Stomp.over() API; both are obsolete and have been replaced throughout.

How the messages flow

Before the code, it helps to hold the round trip in your head, because every section below plugs into it:

  1. The browser opens a SockJS connection to the /websocket endpoint and negotiates STOMP over it.
  2. The client publishes to a destination under /app (the application prefix).
  3. Spring routes that destination to a @MessageMapping (or @SubscribeMapping) controller method.
  4. The method’s return value is sent back through the broker to whatever the client subscribed to: a per-user /user/... destination, or a /topic broadcast.

Maven Dependencies

First, add the Spring messaging and WebSocket modules to the POM:

<dependency>
    <groupId>org.springframework</groupId>
    <artifactId>spring-messaging</artifactId>
    <version>7.0.8</version>
</dependency>
<dependency>
    <groupId>org.springframework</groupId>
    <artifactId>spring-websocket</artifactId>
    <version>7.0.8</version>
</dependency>

If you are on Spring Boot, pull in spring-boot-starter-websocket instead and let the Boot BOM manage the versions.

WebSocket Configuration

Spring 4.1 configured the broker through an XML <websocket:message-broker> element. Modern Spring replaces that with a @Configuration class annotated @EnableWebSocketMessageBroker:

@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {

    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        registry.addEndpoint("/websocket")
                .addInterceptors(new HttpSessionHandshakeInterceptor())
                .withSockJS();
    }

    @Override
    public void configureMessageBroker(MessageBrokerRegistry registry) {
        registry.enableSimpleBroker("/topic", "/message");
        registry.setApplicationDestinationPrefixes("/app");
        registry.setUserDestinationPrefix("/user");
    }
}

A few things worth calling out here:

  • enableSimpleBroker("/topic", "/message") registers an in-memory broker for those destination prefixes. For a real deployment you would point Spring at a full broker (RabbitMQ, ActiveMQ) with enableStompBrokerRelay instead.
  • setApplicationDestinationPrefixes("/app") and setUserDestinationPrefix("/user") define the prefixes the controller and client agree on below.
  • HttpSessionHandshakeInterceptor copies the HTTP session attributes into the WebSocket session during the handshake. By default the HTTP session is not carried into the WebSocket request, so without this interceptor the controller could not read session-scoped data.

Spring MVC Controller

@Controller
public class WebSocketController {

    @MessageMapping("/authorization.action")
    @SendToUser("/message/authorization")
    public Message authorizationAction(
            SimpMessageHeaderAccessor headerAccessor, Message message) {
        String csrfToken = message.value();
        Map<String, Object> sessionAttributes = headerAccessor.getSessionAttributes();
        boolean isCsrfTokenValid = CsrfProtector.isCsrfTokenValid(csrfToken, sessionAttributes);

        return new Message("isCsrfTokenValid", Boolean.toString(isCsrfTokenValid));
    }

    @SubscribeMapping("/getRealTimeJudgeResult.action/{submissionId}")
    public Message getRealTimeJudgeResultAction(@DestinationVariable long submissionId) {
        return new Message("Key", "Value # " + submissionId);
    }

    /* Payload exchanged over STOMP, serialized to/from JSON by Jackson. */
    record Message(String key, String value) {}
}

The payload is a plain Java record: STOMP frames travel as JSON, so Jackson reads and writes the key/value components directly, and there is no need for the old Serializable plumbing.

What each annotation does

  1. SimpMessageHeaderAccessor gives you the WebSocket session, including the HTTP session attributes copied in by the handshake interceptor. That is how authorizationAction reaches getSessionAttributes().
  2. @SendToUser routes the return value to a single subscriber’s session-scoped destination. It only works because setUserDestinationPrefix("/user") is configured above.
  3. @SubscribeMapping returns a value directly in response to a subscription, skipping the broker. It is handy for “fetch once when the client subscribes” data rather than ongoing broadcasts.
  4. @DestinationVariable binds a path segment of the destination ({submissionId}) to a method argument, just like @PathVariable does for HTTP routes.

Client: SockJS and STOMP

On the browser side, use SockJS for the transport and the @stomp/stompjs Client for the protocol:

import { Client } from "@stomp/stompjs";
import SockJS from "sockjs-client";

const stompClient = new Client({
  // SockJS provides a fallback for browsers without native WebSocket support.
  webSocketFactory: () => new SockJS("/websocket"),
  onConnect: () => {
    displayWebSocketConnectionStatus(true);

    // Send the CSRF token for the server to validate.
    stompClient.publish({
      destination: "/app/authorization.action",
      body: JSON.stringify({ key: "csrfToken", value: "${csrfToken}" }),
    });

    // Receive the per-user reply.
    stompClient.subscribe("/user/message/authorization", (message) => {
      console.log(JSON.parse(message.body));
    });

    // Pull a one-off result on subscribe.
    stompClient.subscribe(
      "/app/getRealTimeJudgeResult.action/${submissionId}",
      (message) => console.log(JSON.parse(message.body))
    );
  },
  onWebSocketClose: () => displayWebSocketConnectionStatus(false),
});

stompClient.activate();

Each client destination lines up with a server handler, which is where the two halves of this post meet:

  • publish to /app/authorization.action is dispatched to @MessageMapping("/authorization.action"); Spring strips the /app application prefix before matching.
  • subscribe to /user/message/authorization receives whatever @SendToUser("/message/authorization") returns, scoped to this session by the /user prefix.
  • subscribe to /app/getRealTimeJudgeResult.action/${submissionId} is answered by @SubscribeMapping, with the JSP-rendered ${submissionId} flowing into the @DestinationVariable.

Conclusion

That is the full round trip: a single SockJS connection, a STOMP layer on top, and a controller whose annotations mirror the client’s publish/subscribe destinations. From here you can swap the in-memory broker for a real one, broadcast to /topic instead of a single user, or push server-side events as they happen. For the complete configuration and the broker-relay options, see the references below.

References