Skip to content
Spring Boot sb web 3 min read

WebSockets & STOMP

WebSockets give you a full-duplex, long-lived connection between browser and server — perfect for chat, live dashboards, notifications, and collaborative editing. Plain HTTP is request/response; WebSockets let the server push to the client at any time. Spring Boot supports both raw WebSocket handlers and the higher-level STOMP sub-protocol, which adds messaging semantics like destinations, subscriptions, and a broker.

Setup — spring-boot-starter-websocket

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-websocket</artifactId>
</dependency>

This pulls in Spring’s WebSocket and messaging support on top of spring-boot-starter-web.

Two levels of API

ApproachWhat you writeWhen to use
Raw WebSocketHandlerHandle each text/binary frame yourselfSimple echo, custom binary protocols
STOMP over WebSocket@MessageMapping controllers + a brokerPub/sub, multiple topics, user messaging — most apps

STOMP is the recommended default: it gives you message routing, subscriptions, and a built-in broker, and it maps cleanly to controller methods just like REST.

Raw WebSocketHandler

For the lowest level, implement WebSocketHandler (or extend TextWebSocketHandler) and register it:

@Component
public class EchoHandler extends TextWebSocketHandler {
    @Override
    public void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
        session.sendMessage(new TextMessage("echo: " + message.getPayload()));
    }
}

@Configuration
@EnableWebSocket
public class RawWsConfig implements WebSocketConfigurer {
    private final EchoHandler echoHandler;
    RawWsConfig(EchoHandler echoHandler) { this.echoHandler = echoHandler; }

    @Override
    public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
        registry.addHandler(echoHandler, "/ws/echo").setAllowedOrigins("*");
    }
}

This works but you manage sessions and parse payloads by hand. For anything pub/sub, use STOMP.

STOMP configuration

Enable the broker with @EnableWebSocketMessageBroker and configure endpoints and destination prefixes:

@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {

    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        registry.addEndpoint("/ws")          // client connects here
                .setAllowedOriginPatterns("*")
                .withSockJS();               // SockJS fallback for old browsers
    }

    @Override
    public void configureMessageBroker(MessageBrokerRegistry registry) {
        registry.enableSimpleBroker("/topic", "/queue");  // in-memory broker
        registry.setApplicationDestinationPrefixes("/app"); // routed to @MessageMapping
        registry.setUserDestinationPrefix("/user");         // per-user messaging
    }
}

What the prefixes mean:

  • /app — messages sent here are routed to your @MessageMapping controller methods.
  • /topic, /queue — handled by the simple broker; clients subscribe and the broker fans messages out.
  • /user — converts a destination into a per-session, user-specific one.

Note: enableSimpleBroker runs an in-memory broker — great for a single instance. For clustered deployments use a real broker (RabbitMQ/ActiveMQ) via enableStompBrokerRelay. See RabbitMQ.

@MessageMapping controllers

STOMP controllers look like REST controllers but route by destination instead of URL:

public record ChatMessage(String from, String text) {}

@Controller
public class ChatController {

    // client sends to /app/chat ; result broadcast to /topic/messages
    @MessageMapping("/chat")
    @SendTo("/topic/messages")
    public ChatMessage send(ChatMessage incoming) {
        return new ChatMessage(incoming.from(), incoming.text().trim());
    }
}

@MessageMapping("/chat") matches /app/chat (the application prefix). The returned object is serialized to JSON (via Jackson) and @SendTo publishes it to /topic/messages, reaching every subscriber.

Sending to a specific user

Inject SimpMessagingTemplate to push messages programmatically, including to one user:

@Service
public class NotificationService {
    private final SimpMessagingTemplate messaging;
    NotificationService(SimpMessagingTemplate messaging) { this.messaging = messaging; }

    public void notifyUser(String username, String text) {
        // delivered to that user's subscription on /user/queue/notify
        messaging.convertAndSendToUser(username, "/queue/notify", text);
    }

    public void broadcast(String text) {
        messaging.convertAndSend("/topic/announcements", text);
    }
}

convertAndSendToUser combines the user destination prefix with the session’s Principal, so each user only sees their own messages.

A JavaScript client

Browsers connect with the STOMP.js client over SockJS:

const socket = new SockJS('/ws');
const stomp = Stomp.over(socket);

stomp.connect({}, () => {
  // subscribe to the broadcast topic
  stomp.subscribe('/topic/messages', (frame) => {
    const msg = JSON.parse(frame.body);
    console.log(`${msg.from}: ${msg.text}`);
  });

  // send a chat message to the @MessageMapping handler
  stomp.send('/app/chat', {}, JSON.stringify({ from: 'grace', text: 'hello!' }));
});

Console output (on every connected client):

grace: hello!

Handling connect / disconnect events

Listen for session lifecycle events to track presence:

@Component
public class PresenceListener {

    @EventListener
    public void onConnect(SessionConnectedEvent event) {
        System.out.println("client connected: " + event.getMessage().getHeaders().get("simpSessionId"));
    }

    @EventListener
    public void onDisconnect(SessionDisconnectEvent event) {
        System.out.println("client gone: " + event.getSessionId());
    }
}

Pitfalls

  • CORS: WebSocket origins are separate from MVC CORS — set setAllowedOriginPatterns(...) on the endpoint. See CORS.
  • The simple broker does not survive across multiple instances; use a broker relay for horizontal scaling.
  • WebSocket sessions are stateful and consume memory — clean up on disconnect for large fan-outs.
  • Authentication: secure the handshake with Spring Security and read the Principal for convertAndSendToUser.
Last updated June 13, 2026
Was this helpful?